From 8bfadedc7504d82c125564273faed43d14728f13 Mon Sep 17 00:00:00 2001 From: Eduardo Apolinario Date: Tue, 20 Feb 2024 21:22:22 -0800 Subject: [PATCH 1/9] Add flytekit sans .git Signed-off-by: Eduardo Apolinario --- flytekit/.github/config.yml | 16 + flytekit/.github/workflows/build_image.yml | 86 + flytekit/.github/workflows/monodocs_build.yml | 54 + flytekit/.github/workflows/pythonbuild.yml | 313 + flytekit/.github/workflows/pythonpublish.yml | 275 + .../.github/workflows/upgrade_automation.yml | 12 + flytekit/.gitignore | 39 + flytekit/.pre-commit-config.yaml | 30 + flytekit/.readthedocs.yml | 22 + flytekit/CODEOWNERS | 3 + flytekit/CODE_OF_CONDUCT.md | 2 + flytekit/Dockerfile | 38 + flytekit/Dockerfile.agent | 22 + flytekit/Dockerfile.dev | 49 + flytekit/LICENSE | 202 + flytekit/MANIFEST.in | 51 + flytekit/Makefile | 103 + flytekit/NOTICE | 4 + flytekit/README.md | 78 + flytekit/codecov.yml | 9 + flytekit/dev-requirements.in | 50 + flytekit/dev-requirements.txt | 562 + flytekit/docs/Makefile | 24 + flytekit/docs/make.bat | 36 + flytekit/docs/source/_templates/custom.rst | 42 + .../docs/source/_templates/file_types.rst | 39 + .../docs/source/_templates/sidebar/brand.html | 18 + flytekit/docs/source/clients.rst | 4 + flytekit/docs/source/conf.py | 266 + flytekit/docs/source/configuration.rst | 4 + flytekit/docs/source/contributing.rst | 167 + flytekit/docs/source/deck.rst | 5 + flytekit/docs/source/design/authoring.rst | 161 + flytekit/docs/source/design/clis.rst | 101 + flytekit/docs/source/design/control_plane.rst | 347 + flytekit/docs/source/design/execution.rst | 23 + flytekit/docs/source/design/index.rst | 24 + flytekit/docs/source/design/models.rst | 42 + flytekit/docs/source/docs_index.rst | 22 + flytekit/docs/source/experimental.rst | 16 + flytekit/docs/source/extend.rst | 5 + flytekit/docs/source/extras.accelerators.rst | 4 + flytekit/docs/source/extras.pytorch.rst | 10 + flytekit/docs/source/extras.sklearn.rst | 7 + flytekit/docs/source/extras.sqlite3.rst | 10 + flytekit/docs/source/extras.tasks.rst | 8 + flytekit/docs/source/extras.tensorflow.rst | 8 + .../source/flyte_circle_gradient_1_4x4.png | Bin 0 -> 70985 bytes flytekit/docs/source/flytekit.rst | 4 + flytekit/docs/source/index.rst | 94 + flytekit/docs/source/plugins/athena.rst | 12 + flytekit/docs/source/plugins/awsbatch.rst | 11 + flytekit/docs/source/plugins/awssagemaker.rst | 17 + flytekit/docs/source/plugins/bigquery.rst | 12 + flytekit/docs/source/plugins/dask.rst | 12 + flytekit/docs/source/plugins/dbt.rst | 12 + flytekit/docs/source/plugins/deck.rst | 12 + flytekit/docs/source/plugins/dolt.rst | 12 + flytekit/docs/source/plugins/duckdb.rst | 12 + flytekit/docs/source/plugins/fsspec.rst | 14 + .../docs/source/plugins/greatexpectations.rst | 12 + flytekit/docs/source/plugins/hive.rst | 12 + flytekit/docs/source/plugins/index.rst | 63 + flytekit/docs/source/plugins/kfmpi.rst | 12 + flytekit/docs/source/plugins/kfpytorch.rst | 12 + flytekit/docs/source/plugins/kftensorflow.rst | 12 + flytekit/docs/source/plugins/mlflow.rst | 9 + flytekit/docs/source/plugins/modin.rst | 12 + flytekit/docs/source/plugins/onnxpytorch.rst | 12 + .../docs/source/plugins/onnxscikitlearn.rst | 12 + .../docs/source/plugins/onnxtensorflow.rst | 12 + flytekit/docs/source/plugins/pandera.rst | 12 + flytekit/docs/source/plugins/papermill.rst | 12 + flytekit/docs/source/plugins/pod.rst | 12 + flytekit/docs/source/plugins/ray.rst | 12 + flytekit/docs/source/plugins/snowflake.rst | 12 + flytekit/docs/source/plugins/spark.rst | 12 + flytekit/docs/source/plugins/sqlalchemy.rst | 12 + flytekit/docs/source/plugins/vaex.rst | 10 + flytekit/docs/source/pyflyte.rst | 7 + flytekit/docs/source/remote.rst | 4 + flytekit/docs/source/tasks.extend.rst | 25 + flytekit/docs/source/testing.rst | 5 + .../docs/source/types.builtins.directory.rst | 4 + flytekit/docs/source/types.builtins.file.rst | 4 + .../docs/source/types.builtins.structured.rst | 4 + flytekit/docs/source/types.extend.rst | 19 + flytekit/flytekit/__init__.py | 309 + flytekit/flytekit/clients/__init__.py | 19 + flytekit/flytekit/clients/auth/__init__.py | 0 flytekit/flytekit/clients/auth/auth_client.py | 393 + .../flytekit/clients/auth/authenticator.py | 323 + .../flytekit/clients/auth/default_html.py | 22 + flytekit/flytekit/clients/auth/exceptions.py | 22 + flytekit/flytekit/clients/auth/keyring.py | 81 + .../flytekit/clients/auth/token_client.py | 183 + flytekit/flytekit/clients/auth_helper.py | 297 + flytekit/flytekit/clients/friendly.py | 1038 ++ .../flytekit/clients/grpc_utils/__init__.py | 0 .../clients/grpc_utils/auth_interceptor.py | 80 + .../default_metadata_interceptor.py | 43 + .../grpc_utils/wrap_exception_interceptor.py | 49 + flytekit/flytekit/clients/helpers.py | 77 + flytekit/flytekit/clients/raw.py | 594 + flytekit/flytekit/clis/__init__.py | 0 flytekit/flytekit/clis/flyte_cli/__init__.py | 0 .../flytekit/clis/flyte_cli/example.config | 11 + flytekit/flytekit/clis/flyte_cli/main.py | 2210 ++++ flytekit/flytekit/clis/helpers.py | 139 + .../clis/sdk_in_container/__init__.py | 0 .../clis/sdk_in_container/backfill.py | 182 + .../flytekit/clis/sdk_in_container/build.py | 95 + .../clis/sdk_in_container/constants.py | 8 + .../flytekit/clis/sdk_in_container/fetch.py | 48 + .../flytekit/clis/sdk_in_container/get.py | 87 + .../flytekit/clis/sdk_in_container/helpers.py | 63 + .../flytekit/clis/sdk_in_container/init.py | 37 + .../clis/sdk_in_container/launchplan.py | 69 + .../clis/sdk_in_container/local_cache.py | 22 + .../flytekit/clis/sdk_in_container/metrics.py | 218 + .../flytekit/clis/sdk_in_container/package.py | 141 + .../flytekit/clis/sdk_in_container/pyflyte.py | 102 + .../clis/sdk_in_container/register.py | 202 + .../flytekit/clis/sdk_in_container/run.py | 885 ++ .../clis/sdk_in_container/serialize.py | 221 + .../flytekit/clis/sdk_in_container/serve.py | 71 + .../flytekit/clis/sdk_in_container/utils.py | 169 + flytekit/flytekit/clis/version.py | 27 + flytekit/flytekit/configuration/__init__.py | 933 ++ .../flytekit/configuration/default_images.py | 58 + .../flytekit/configuration/feature_flags.py | 16 + flytekit/flytekit/configuration/file.py | 310 + flytekit/flytekit/configuration/internal.py | 204 + flytekit/flytekit/configuration/plugin.py | 117 + flytekit/flytekit/core/__init__.py | 0 flytekit/flytekit/core/annotation.py | 30 + flytekit/flytekit/core/array_node_map_task.py | 398 + flytekit/flytekit/core/artifact.py | 515 + flytekit/flytekit/core/base_sql_task.py | 77 + flytekit/flytekit/core/base_task.py | 792 ++ flytekit/flytekit/core/checkpointer.py | 158 + .../flytekit/core/class_based_resolver.py | 43 + flytekit/flytekit/core/condition.py | 524 + flytekit/flytekit/core/constants.py | 11 + flytekit/flytekit/core/container_task.py | 152 + flytekit/flytekit/core/context_manager.py | 900 ++ flytekit/flytekit/core/data_persistence.py | 526 + flytekit/flytekit/core/docstring.py | 27 + .../flytekit/core/dynamic_workflow_task.py | 53 + flytekit/flytekit/core/gate.py | 217 + flytekit/flytekit/core/hash.py | 23 + flytekit/flytekit/core/interface.py | 499 + flytekit/flytekit/core/launch_plan.py | 483 + flytekit/flytekit/core/local_cache.py | 73 + flytekit/flytekit/core/local_fsspec.py | 27 + flytekit/flytekit/core/map_task.py | 415 + flytekit/flytekit/core/mock_stats.py | 64 + flytekit/flytekit/core/node.py | 245 + flytekit/flytekit/core/node_creation.py | 164 + flytekit/flytekit/core/notification.py | 115 + flytekit/flytekit/core/pod_template.py | 27 + flytekit/flytekit/core/promise.py | 1219 ++ .../flytekit/core/python_auto_container.py | 317 + .../core/python_customized_container_task.py | 248 + .../flytekit/core/python_function_task.py | 347 + flytekit/flytekit/core/reference.py | 50 + flytekit/flytekit/core/reference_entity.py | 266 + flytekit/flytekit/core/resources.py | 87 + flytekit/flytekit/core/schedule.py | 204 + flytekit/flytekit/core/shim_task.py | 173 + flytekit/flytekit/core/task.py | 370 + flytekit/flytekit/core/testing.py | 79 + flytekit/flytekit/core/tracked_abc.py | 11 + flytekit/flytekit/core/tracker.py | 349 + flytekit/flytekit/core/type_engine.py | 2094 ++++ flytekit/flytekit/core/type_helpers.py | 26 + flytekit/flytekit/core/utils.py | 392 + flytekit/flytekit/core/workflow.py | 916 ++ flytekit/flytekit/deck/__init__.py | 18 + flytekit/flytekit/deck/deck.py | 176 + flytekit/flytekit/deck/html/__init__.py | 0 flytekit/flytekit/deck/html/template.html | 131 + flytekit/flytekit/deck/renderer.py | 50 + flytekit/flytekit/exceptions/__init__.py | 0 flytekit/flytekit/exceptions/base.py | 12 + flytekit/flytekit/exceptions/scopes.py | 232 + flytekit/flytekit/exceptions/system.py | 43 + flytekit/flytekit/exceptions/user.py | 99 + flytekit/flytekit/experimental/__init__.py | 4 + .../flytekit/experimental/eager_function.py | 624 + flytekit/flytekit/extend/__init__.py | 44 + flytekit/flytekit/extend/backend/__init__.py | 0 .../flytekit/extend/backend/agent_service.py | 125 + .../flytekit/extend/backend/base_agent.py | 273 + flytekit/flytekit/extras/__init__.py | 0 flytekit/flytekit/extras/accelerators.py | 278 + .../flytekit/extras/cloud_pickle_resolver.py | 38 + flytekit/flytekit/extras/pytorch/__init__.py | 32 + .../flytekit/extras/pytorch/checkpoint.py | 135 + flytekit/flytekit/extras/pytorch/native.py | 101 + flytekit/flytekit/extras/sklearn/__init__.py | 26 + flytekit/flytekit/extras/sklearn/native.py | 88 + flytekit/flytekit/extras/sqlite3/__init__.py | 12 + flytekit/flytekit/extras/sqlite3/task.py | 135 + flytekit/flytekit/extras/tasks/__init__.py | 12 + flytekit/flytekit/extras/tasks/shell.py | 473 + .../flytekit/extras/tensorflow/__init__.py | 35 + flytekit/flytekit/extras/tensorflow/model.py | 75 + flytekit/flytekit/extras/tensorflow/record.py | 184 + flytekit/flytekit/image_spec/__init__.py | 1 + flytekit/flytekit/image_spec/image_spec.py | 279 + flytekit/flytekit/interaction/__init__.py | 0 flytekit/flytekit/interaction/click_types.py | 369 + flytekit/flytekit/interaction/parse_stdin.py | 35 + flytekit/flytekit/interaction/rich_utils.py | 22 + .../flytekit/interaction/string_literals.py | 72 + flytekit/flytekit/interfaces/__init__.py | 0 .../flytekit/interfaces/cli_identifiers.py | 180 + flytekit/flytekit/interfaces/random.py | 23 + .../flytekit/interfaces/stats/__init__.py | 2 + flytekit/flytekit/interfaces/stats/client.py | 156 + .../flytekit/interfaces/stats/taggable.py | 96 + flytekit/flytekit/lazy_import/__init__.py | 0 flytekit/flytekit/lazy_import/lazy_module.py | 47 + flytekit/flytekit/loggers.py | 146 + flytekit/flytekit/models/__init__.py | 0 flytekit/flytekit/models/admin/__init__.py | 0 flytekit/flytekit/models/admin/common.py | 70 + .../flytekit/models/admin/task_execution.py | 195 + flytekit/flytekit/models/admin/workflow.py | 139 + flytekit/flytekit/models/annotation.py | 48 + flytekit/flytekit/models/array_job.py | 108 + flytekit/flytekit/models/common.py | 505 + flytekit/flytekit/models/core/__init__.py | 0 flytekit/flytekit/models/core/catalog.py | 75 + flytekit/flytekit/models/core/compiler.py | 210 + flytekit/flytekit/models/core/condition.py | 241 + flytekit/flytekit/models/core/errors.py | 93 + flytekit/flytekit/models/core/execution.py | 224 + flytekit/flytekit/models/core/identifier.py | 282 + flytekit/flytekit/models/core/types.py | 77 + flytekit/flytekit/models/core/workflow.py | 1006 ++ flytekit/flytekit/models/documentation.py | 93 + flytekit/flytekit/models/dynamic_job.py | 99 + flytekit/flytekit/models/execution.py | 781 ++ flytekit/flytekit/models/filters.py | 133 + flytekit/flytekit/models/interface.py | 270 + flytekit/flytekit/models/launch_plan.py | 424 + flytekit/flytekit/models/literals.py | 956 ++ .../flytekit/models/matchable_resource.py | 362 + flytekit/flytekit/models/named_entity.py | 122 + flytekit/flytekit/models/node_execution.py | 317 + flytekit/flytekit/models/presto.py | 80 + flytekit/flytekit/models/project.py | 78 + flytekit/flytekit/models/qubole.py | 170 + flytekit/flytekit/models/schedule.py | 169 + flytekit/flytekit/models/security.py | 175 + flytekit/flytekit/models/task.py | 954 ++ flytekit/flytekit/models/types.py | 508 + flytekit/flytekit/models/workflow_closure.py | 49 + flytekit/flytekit/remote/__init__.py | 99 + flytekit/flytekit/remote/backfill.py | 107 + flytekit/flytekit/remote/data.py | 55 + flytekit/flytekit/remote/entities.py | 840 ++ flytekit/flytekit/remote/executions.py | 212 + flytekit/flytekit/remote/interface.py | 11 + flytekit/flytekit/remote/lazy_entity.py | 67 + flytekit/flytekit/remote/remote.py | 2085 ++++ flytekit/flytekit/remote/remote_callable.py | 75 + flytekit/flytekit/remote/remote_fs.py | 312 + flytekit/flytekit/sensor/__init__.py | 3 + flytekit/flytekit/sensor/base_sensor.py | 66 + flytekit/flytekit/sensor/file_sensor.py | 18 + flytekit/flytekit/sensor/sensor_engine.py | 62 + flytekit/flytekit/testing/__init__.py | 20 + flytekit/flytekit/tools/__init__.py | 0 flytekit/flytekit/tools/fast_registration.py | 129 + flytekit/flytekit/tools/ignore.py | 125 + flytekit/flytekit/tools/interactive.py | 14 + flytekit/flytekit/tools/module_loader.py | 44 + flytekit/flytekit/tools/repo.py | 299 + flytekit/flytekit/tools/script_mode.py | 161 + flytekit/flytekit/tools/serialize_helpers.py | 100 + flytekit/flytekit/tools/subprocess.py | 30 + flytekit/flytekit/tools/translator.py | 800 ++ flytekit/flytekit/types/__init__.py | 0 flytekit/flytekit/types/directory/__init__.py | 37 + flytekit/flytekit/types/directory/types.py | 475 + flytekit/flytekit/types/error/__init__.py | 12 + flytekit/flytekit/types/error/error.py | 58 + flytekit/flytekit/types/file/__init__.py | 116 + flytekit/flytekit/types/file/file.py | 511 + flytekit/flytekit/types/file/image.py | 81 + flytekit/flytekit/types/iterator/__init__.py | 10 + flytekit/flytekit/types/iterator/iterator.py | 67 + flytekit/flytekit/types/numpy/__init__.py | 1 + flytekit/flytekit/types/numpy/ndarray.py | 92 + flytekit/flytekit/types/pickle/__init__.py | 12 + flytekit/flytekit/types/pickle/pickle.py | 125 + flytekit/flytekit/types/schema/__init__.py | 11 + flytekit/flytekit/types/schema/types.py | 446 + .../flytekit/types/schema/types_pandas.py | 128 + .../flytekit/types/structured/__init__.py | 71 + .../flytekit/types/structured/basic_dfs.py | 192 + .../flytekit/types/structured/bigquery.py | 116 + .../types/structured/structured_dataset.py | 868 ++ flytekit/flytekit_scripts/Readme.rst | 4 + .../flytekit_scripts/flytekit_build_image.sh | 100 + flytekit/flytekit_scripts/flytekit_venv | 10 + flytekit/plugins/Makefile | 25 + flytekit/plugins/README.md | 150 + flytekit/plugins/__init__.py | 0 flytekit/plugins/flytekit-airflow/README.md | 33 + .../flytekit-airflow/dev-requirements.in | 2 + .../flytekit-airflow/dev-requirements.txt | 986 ++ .../flytekitplugins/airflow/__init__.py | 16 + .../flytekitplugins/airflow/agent.py | 145 + .../flytekitplugins/airflow/task.py | 231 + flytekit/plugins/flytekit-airflow/setup.py | 41 + .../flytekit-airflow/tests/__init__.py | 0 .../flytekit-airflow/tests/test_agent.py | 98 + .../flytekit-airflow/tests/test_task.py | 122 + .../plugins/flytekit-async-fsspec/README.md | 14 + .../flytekitplugins/async_fsspec/__init__.py | 16 + .../async_fsspec/s3fs/__init__.py | 0 .../async_fsspec/s3fs/constants.py | 6 + .../flytekitplugins/async_fsspec/s3fs/s3fs.py | 240 + .../plugins/flytekit-async-fsspec/setup.py | 37 + .../flytekit-async-fsspec/tests/__init__.py | 0 .../flytekit-async-fsspec/tests/test_s3fs.py | 201 + .../plugins/flytekit-aws-athena/README.md | 11 + .../flytekitplugins/athena/__init__.py | 14 + .../flytekitplugins/athena/task.py | 80 + flytekit/plugins/flytekit-aws-athena/setup.py | 35 + .../flytekit-aws-athena/tests/__init__.py | 0 .../flytekit-aws-athena/tests/test_athena.py | 74 + flytekit/plugins/flytekit-aws-batch/README.md | 9 + .../flytekitplugins/awsbatch/__init__.py | 13 + .../flytekitplugins/awsbatch/task.py | 80 + flytekit/plugins/flytekit-aws-batch/setup.py | 35 + .../flytekit-aws-batch/tests/__init__.py | 0 .../tests/test_aws_batch.py | 49 + .../plugins/flytekit-aws-sagemaker/README.md | 13 + .../flytekitplugins/awssagemaker/__init__.py | 85 + .../awssagemaker/distributed_training.py | 82 + .../flytekitplugins/awssagemaker/hpo.py | 168 + .../awssagemaker/models/__init__.py | 0 .../awssagemaker/models/hpo_job.py | 181 + .../awssagemaker/models/parameter_ranges.py | 315 + .../awssagemaker/models/training_job.py | 326 + .../flytekitplugins/awssagemaker/training.py | 192 + .../scripts/flytekit_sagemaker_runner.py | 92 + .../plugins/flytekit-aws-sagemaker/setup.py | 37 + .../flytekit-aws-sagemaker/tests/__init__.py | 0 .../tests/test_flytekit_sagemaker_running.py | 37 + .../flytekit-aws-sagemaker/tests/test_hpo.py | 124 + .../tests/test_hpo_job.py | 79 + .../tests/test_parameter_ranges.py | 93 + .../tests/test_training.py | 134 + .../tests/test_training_job.py | 87 + flytekit/plugins/flytekit-bigquery/README.md | 11 + .../flytekitplugins/bigquery/__init__.py | 16 + .../flytekitplugins/bigquery/agent.py | 126 + .../flytekitplugins/bigquery/task.py | 85 + flytekit/plugins/flytekit-bigquery/setup.py | 36 + .../flytekit-bigquery/tests/__init__.py | 0 .../flytekit-bigquery/tests/test_agent.py | 105 + .../flytekit-bigquery/tests/test_bigquery.py | 72 + flytekit/plugins/flytekit-dask/README.md | 21 + .../flytekitplugins/dask/__init__.py | 15 + .../flytekitplugins/dask/models.py | 134 + .../flytekitplugins/dask/task.py | 108 + flytekit/plugins/flytekit-dask/setup.py | 42 + .../plugins/flytekit-dask/tests/__init__.py | 0 .../flytekit-dask/tests/test_models.py | 96 + .../plugins/flytekit-dask/tests/test_task.py | 86 + .../plugins/flytekit-data-fsspec/README.md | 5 + .../flytekitplugins/fsspec/__init__.py | 0 .../plugins/flytekit-data-fsspec/setup.py | 44 + .../flytekit-data-fsspec/tests/__init__.py | 0 .../tests/test_placeholder.py | 3 + flytekit/plugins/flytekit-dbt/README.md | 15 + .../plugins/flytekit-dbt/dev-requirements.in | 2 + .../plugins/flytekit-dbt/dev-requirements.txt | 124 + .../flytekitplugins/dbt/__init__.py | 21 + .../flytekit-dbt/flytekitplugins/dbt/error.py | 49 + .../flytekitplugins/dbt/schema.py | 250 + .../flytekit-dbt/flytekitplugins/dbt/task.py | 361 + .../flytekit-dbt/flytekitplugins/dbt/util.py | 40 + flytekit/plugins/flytekit-dbt/setup.py | 42 + .../plugins/flytekit-dbt/tests/__init__.py | 0 .../plugins/flytekit-dbt/tests/test_schema.py | 334 + .../plugins/flytekit-dbt/tests/test_task.py | 225 + .../tests/testdata/jaffle_shop/.gitignore | 5 + .../tests/testdata/jaffle_shop/LICENSE | 201 + .../tests/testdata/jaffle_shop/README.md | 94 + .../testdata/jaffle_shop/dbt_project.yml | 26 + .../jaffle_shop/etc/dbdiagram_definition.txt | 23 + .../jaffle_shop/etc/jaffle_shop_erd.png | Bin 0 -> 58179 bytes .../testdata/jaffle_shop/models/customers.sql | 69 + .../tests/testdata/jaffle_shop/models/docs.md | 14 + .../testdata/jaffle_shop/models/orders.sql | 56 + .../testdata/jaffle_shop/models/overview.md | 11 + .../testdata/jaffle_shop/models/schema.yml | 82 + .../jaffle_shop/models/staging/schema.yml | 31 + .../models/staging/stg_customers.sql | 22 + .../jaffle_shop/models/staging/stg_orders.sql | 23 + .../models/staging/stg_payments.sql | 25 + .../tests/testdata/jaffle_shop/seeds/.gitkeep | 0 .../jaffle_shop/seeds/raw_customers.csv | 101 + .../testdata/jaffle_shop/seeds/raw_orders.csv | 100 + .../jaffle_shop/seeds/raw_payments.csv | 114 + ...ssert_total_payment_amount_is_positive.sql | 6 + .../tests/testdata/profiles/profiles.yml | 11 + .../plugins/flytekit-deck-standard/README.md | 9 + .../flytekitplugins/deck/__init__.py | 17 + .../flytekitplugins/deck/renderer.py | 203 + .../plugins/flytekit-deck-standard/setup.py | 48 + .../flytekit-deck-standard/tests/__init__.py | 0 .../tests/test_renderer.py | 97 + flytekit/plugins/flytekit-dolt/README.md | 19 + .../flytekitplugins/dolt/__init__.py | 15 + .../flytekitplugins/dolt/schema.py | 108 + .../scripts/flytekit_install_dolt.sh | 12 + .../plugins/flytekit-dolt/scripts/install.sh | 112 + flytekit/plugins/flytekit-dolt/setup.py | 52 + .../plugins/flytekit-dolt/tests/__init__.py | 0 .../flytekit-dolt/tests/test_schema.py | 66 + .../plugins/flytekit-dolt/tests/test_wf.py | 216 + flytekit/plugins/flytekit-duckdb/README.md | 9 + .../flytekitplugins/duckdb/__init__.py | 11 + .../flytekitplugins/duckdb/task.py | 121 + flytekit/plugins/flytekit-duckdb/setup.py | 36 + .../flytekit-duckdb/tests/test_task.py | 148 + flytekit/plugins/flytekit-envd/README.md | 44 + .../flytekitplugins/envd/__init__.py | 13 + .../flytekitplugins/envd/image_builder.py | 124 + flytekit/plugins/flytekit-envd/setup.py | 37 + .../plugins/flytekit-envd/tests/__init__.py | 0 .../flytekit-envd/tests/test_image_spec.py | 86 + .../flytekit-flyteinteractive/Dockerfile | 45 + .../flytekit-flyteinteractive/README.md | 105 + .../docs/example.png | Bin 0 -> 905620 bytes .../flyteinteractive/__init__.py | 41 + .../flyteinteractive/jupyter_lib/__init__.py | 0 .../flyteinteractive/jupyter_lib/constants.py | 3 + .../flyteinteractive/jupyter_lib/decorator.py | 64 + .../flytekitplugins/flyteinteractive/utils.py | 57 + .../flyteinteractive/vscode_lib/__init__.py | 0 .../flyteinteractive/vscode_lib/config.py | 59 + .../flyteinteractive/vscode_lib/constants.py | 41 + .../flyteinteractive/vscode_lib/decorator.py | 489 + .../flytekit-flyteinteractive/setup.py | 41 + .../tests/__init__.py | 0 .../tests/test_flyin_plugin.py | 423 + .../tests/test_utils.py | 20 + .../tests/testdata/inputs.pb | Bin 0 -> 26 bytes .../tests/testdata/task.py | 9 + .../flytekit-greatexpectations/README.md | 76 + .../dev-requirements.in | 1 + .../dev-requirements.txt | 341 + .../great_expectations/__init__.py | 17 + .../great_expectations/schema.py | 327 + .../great_expectations/task.py | 250 + .../flytekit-greatexpectations/setup.py | 41 + .../tests/__init__.py | 0 .../tests/data/movies.sqlite | Bin 0 -> 1953792 bytes .../data/yellow_tripdata_sample_2019-01.csv | 10001 ++++++++++++++++ .../data/yellow_tripdata_sample_2019-02.csv | 10001 ++++++++++++++++ .../tests/great_expectations/.gitignore | 2 + .../expectations/sqlite/movies.json | 122 + .../expectations/test/demo.json | 445 + .../expectations/test/demo_pyspark.json | 445 + .../expectations/test1/demo.json | 701 ++ .../great_expectations/great_expectations.yml | 159 + .../styles/data_docs_custom_styles.css | 22 + .../tests/test_schema.py | 318 + .../tests/test_task.py | 412 + flytekit/plugins/flytekit-hive/README.md | 11 + .../flytekitplugins/hive/__init__.py | 15 + .../flytekitplugins/hive/task.py | 138 + flytekit/plugins/flytekit-hive/setup.py | 35 + .../plugins/flytekit-hive/tests/__init__.py | 0 .../flytekit-hive/tests/test_hive_task.py | 131 + .../plugins/flytekit-huggingface/README.md | 10 + .../flytekitplugins/huggingface/__init__.py | 14 + .../huggingface/sd_transformers.py | 81 + .../plugins/flytekit-huggingface/setup.py | 41 + .../flytekit-huggingface/tests/__init__.py | 0 .../tests/test_huggingface_plugin_sd.py | 85 + .../flytekit-identity-aware-proxy/README.md | 404 + .../identity_aware_proxy/__init__.py | 0 .../identity_aware_proxy/cli.py | 248 + .../flytekit-identity-aware-proxy/setup.py | 51 + .../tests/__init__.py | 0 .../tests/test_flytekitplugins_iap.py | 146 + flytekit/plugins/flytekit-k8s-pod/README.md | 13 + .../flytekitplugins/pod/__init__.py | 13 + .../flytekitplugins/pod/task.py | 129 + flytekit/plugins/flytekit-k8s-pod/setup.py | 38 + .../flytekit-k8s-pod/tests/__init__.py | 0 .../flytekit-k8s-pod/tests/test_pod.py | 509 + flytekit/plugins/flytekit-kf-mpi/README.md | 74 + .../flytekitplugins/kfmpi/__init__.py | 13 + .../flytekitplugins/kfmpi/task.py | 297 + flytekit/plugins/flytekit-kf-mpi/setup.py | 36 + .../plugins/flytekit-kf-mpi/tests/__init__.py | 0 .../flytekit-kf-mpi/tests/test_mpi_task.py | 212 + .../plugins/flytekit-kf-pytorch/README.md | 64 + .../flytekitplugins/kfpytorch/__init__.py | 14 + .../kfpytorch/error_handling.py | 44 + .../flytekitplugins/kfpytorch/task.py | 457 + flytekit/plugins/flytekit-kf-pytorch/setup.py | 38 + .../flytekit-kf-pytorch/tests/__init__.py | 0 .../tests/test_elastic_task.py | 189 + .../tests/test_pytorch_task.py | 143 + .../plugins/flytekit-kf-tensorflow/README.md | 54 + .../flytekitplugins/kftensorflow/__init__.py | 13 + .../flytekitplugins/kftensorflow/task.py | 220 + .../plugins/flytekit-kf-tensorflow/setup.py | 35 + .../flytekit-kf-tensorflow/tests/__init__.py | 0 .../tests/test_tensorflow_task.py | 256 + flytekit/plugins/flytekit-mlflow/README.md | 22 + .../flytekitplugins/mlflow/__init__.py | 13 + .../flytekitplugins/mlflow/tracking.py | 136 + flytekit/plugins/flytekit-mlflow/setup.py | 36 + .../plugins/flytekit-mlflow/tests/__init__.py | 0 .../tests/test_mlflow_tracking.py | 32 + flytekit/plugins/flytekit-mmcloud/README.md | 104 + .../flytekitplugins/mmcloud/__init__.py | 16 + .../flytekitplugins/mmcloud/agent.py | 209 + .../flytekitplugins/mmcloud/task.py | 64 + .../flytekitplugins/mmcloud/utils.py | 89 + flytekit/plugins/flytekit-mmcloud/setup.py | 39 + .../flytekit-mmcloud/tests/__init__.py | 0 .../flytekit-mmcloud/tests/test_mmcloud.py | 233 + flytekit/plugins/flytekit-modin/README.md | 13 + .../flytekitplugins/modin/__init__.py | 13 + .../flytekitplugins/modin/schema.py | 118 + flytekit/plugins/flytekit-modin/setup.py | 38 + .../plugins/flytekit-modin/tests/__init__.py | 0 .../flytekit-modin/tests/test_modin_plugin.py | 31 + .../plugins/flytekit-onnx-pytorch/README.md | 9 + .../flytekit-onnx-pytorch/dev-requirements.in | 3 + .../dev-requirements.txt | 93 + .../flytekitplugins/onnxpytorch/__init__.py | 12 + .../flytekitplugins/onnxpytorch/schema.py | 140 + .../plugins/flytekit-onnx-pytorch/setup.py | 35 + .../flytekit-onnx-pytorch/tests/__init__.py | 0 .../tests/test_onnx_pytorch.py | 130 + .../flytekit-onnx-scikitlearn/README.md | 9 + .../dev-requirements.in | 1 + .../dev-requirements.txt | 24 + .../onnxscikitlearn/__init__.py | 12 + .../flytekitplugins/onnxscikitlearn/schema.py | 159 + .../flytekit-onnx-scikitlearn/setup.py | 35 + .../tests/__init__.py | 0 .../tests/test_onnx_scikitlearn.py | 115 + .../flytekit-onnx-tensorflow/README.md | 9 + .../dev-requirements.in | 2 + .../dev-requirements.txt | 26 + .../onnxtensorflow/__init__.py | 12 + .../flytekitplugins/onnxtensorflow/schema.py | 130 + .../plugins/flytekit-onnx-tensorflow/setup.py | 35 + .../tests/__init__.py | 0 .../tests/test_onnx_tf.py | 78 + flytekit/plugins/flytekit-pandera/README.md | 11 + .../flytekitplugins/pandera/__init__.py | 13 + .../flytekitplugins/pandera/schema.py | 101 + flytekit/plugins/flytekit-pandera/setup.py | 35 + .../flytekit-pandera/tests/test_plugin.py | 98 + flytekit/plugins/flytekit-papermill/README.md | 11 + .../flytekit-papermill/dev-requirements.in | 4 + .../flytekit-papermill/dev-requirements.txt | 379 + .../flytekitplugins/papermill/__init__.py | 14 + .../flytekitplugins/papermill/task.py | 402 + flytekit/plugins/flytekit-papermill/setup.py | 40 + .../flytekit-papermill/tests/__init__.py | 0 .../tests/test_spark_notebook.py | 38 + .../flytekit-papermill/tests/test_task.py | 265 + .../tests/testdata/__init__.py | 0 .../tests/testdata/datatype.py | 8 + flytekit/plugins/flytekit-polars/README.md | 10 + .../flytekitplugins/polars/__init__.py | 14 + .../flytekitplugins/polars/sd_transformers.py | 87 + flytekit/plugins/flytekit-polars/setup.py | 35 + .../plugins/flytekit-polars/tests/__init__.py | 0 .../tests/test_polars_plugin_sd.py | 100 + flytekit/plugins/flytekit-pydantic/README.md | 28 + .../flytekitplugins/pydantic/__init__.py | 4 + .../pydantic/basemodel_transformer.py | 68 + .../flytekitplugins/pydantic/commons.py | 33 + .../pydantic/deserialization.py | 147 + .../flytekitplugins/pydantic/serialization.py | 117 + flytekit/plugins/flytekit-pydantic/setup.py | 40 + .../tests/folder/test_file1.txt | 1 + .../tests/folder/test_file2.txt | 1 + .../tests/test_type_transformer.py | 296 + flytekit/plugins/flytekit-ray/README.md | 9 + .../flytekitplugins/ray/__init__.py | 15 + .../flytekitplugins/ray/models.py | 204 + .../flytekit-ray/flytekitplugins/ray/task.py | 78 + flytekit/plugins/flytekit-ray/setup.py | 35 + .../plugins/flytekit-ray/tests/__init__.py | 0 .../plugins/flytekit-ray/tests/test_ray.py | 68 + flytekit/plugins/flytekit-snowflake/README.md | 13 + .../flytekit-snowflake/dev-requirements.in | 1 + .../flytekit-snowflake/dev-requirements.txt | 20 + .../flytekitplugins/snowflake/__init__.py | 16 + .../flytekitplugins/snowflake/agent.py | 150 + .../flytekitplugins/snowflake/task.py | 96 + flytekit/plugins/flytekit-snowflake/setup.py | 36 + .../flytekit-snowflake/tests/__init__.py | 0 .../flytekit-snowflake/tests/test_agent.py | 121 + .../tests/test_snowflake.py | 89 + flytekit/plugins/flytekit-spark/Dockerfile | 20 + flytekit/plugins/flytekit-spark/README.md | 13 + .../flytekit-spark/dev-requirements.in | 2 + .../flytekit-spark/dev-requirements.txt | 42 + .../flytekitplugins/spark/__init__.py | 24 + .../flytekitplugins/spark/agent.py | 127 + .../flytekitplugins/spark/models.py | 208 + .../spark/pyspark_transformers.py | 42 + .../flytekitplugins/spark/schema.py | 102 + .../flytekitplugins/spark/sd_transformers.py | 74 + .../flytekitplugins/spark/task.py | 198 + .../scripts/flytekit_install_spark3.sh | 47 + flytekit/plugins/flytekit-spark/setup.py | 38 + .../plugins/flytekit-spark/tests/__init__.py | 0 .../flytekit-spark/tests/test_agent.py | 137 + .../tests/test_pyspark_transformers.py | 53 + .../tests/test_remote_register.py | 46 + .../flytekit-spark/tests/test_spark_task.py | 123 + .../plugins/flytekit-spark/tests/test_wf.py | 117 + .../plugins/flytekit-sqlalchemy/Dockerfile | 19 + .../plugins/flytekit-sqlalchemy/README.md | 11 + .../flytekitplugins/sqlalchemy/__init__.py | 15 + .../flytekitplugins/sqlalchemy/task.py | 140 + flytekit/plugins/flytekit-sqlalchemy/setup.py | 35 + .../flytekit-sqlalchemy/tests/__init__.py | 0 .../tests/test_sql_tracker.py | 30 + .../flytekit-sqlalchemy/tests/test_task.py | 234 + flytekit/plugins/flytekit-vaex/README.md | 11 + .../flytekitplugins/vaex/__init__.py | 14 + .../flytekitplugins/vaex/sd_transformers.py | 72 + flytekit/plugins/flytekit-vaex/setup.py | 42 + .../plugins/flytekit-vaex/tests/__init__.py | 0 .../tests/test_vaex_plugin_sd.py | 89 + flytekit/plugins/flytekit-whylogs/README.md | 57 + .../flytekitplugins/whylogs/__init__.py | 4 + .../flytekitplugins/whylogs/renderer.py | 66 + .../flytekitplugins/whylogs/schema.py | 48 + .../plugins/flytekit-whylogs/requirements.in | 2 + .../plugins/flytekit-whylogs/requirements.txt | 245 + flytekit/plugins/flytekit-whylogs/setup.py | 39 + .../flytekit-whylogs/tests/__init__.py | 0 .../flytekit-whylogs/tests/test_renderer.py | 73 + .../flytekit-whylogs/tests/test_schema.py | 49 + flytekit/plugins/run_all_plugins.sh | 18 + flytekit/plugins/setup.py | 97 + flytekit/pull_request_template.md | 51 + flytekit/pyproject.toml | 143 + flytekit/requirements.in | 2 + flytekit/setup.cfg | 8 + flytekit/setup.py | 3 + flytekit/tests/__init__.py | 0 flytekit/tests/flytekit/__init__.py | 0 flytekit/tests/flytekit/common/__init__.py | 0 .../common/configs/deprecated_local.config | 12 + .../flytekit/common/configs/local.config | 14 + .../tests/flytekit/common/parameterizers.py | 269 + flytekit/tests/flytekit/conftest.py | 11 + .../tests/flytekit/integration/__init__.py | 0 .../integration/experimental/__init__.py | 0 .../experimental/eager_workflows.py | 154 + .../experimental/test_eager_workflows.py | 116 + .../flytekit/integration/remote/__init__.py | 0 .../workflows/requirements.txt | 354 + .../integration/remote/test_remote.py | 352 + .../remote/workflows/basic/basic_workflow.py | 54 + .../remote/workflows/basic/child_workflow.py | 33 + .../remote/workflows/basic/dict_str_wf.py | 18 + .../remote/workflows/basic/hello_world.py | 40 + .../remote/workflows/basic/joblib.py | 23 + .../remote/workflows/basic/list_float_wf.py | 17 + .../remote/workflows/basic/subworkflows.py | 26 + flytekit/tests/flytekit/unit/__init__.py | 0 .../flytekit/unit/cli/pyflyte/__init__.py | 0 .../flytekit/unit/cli/pyflyte/conftest.py | 71 + .../default_arguments/collection_wf.py | 13 + .../pyflyte/default_arguments/dataclass_wf.py | 21 + .../cli/pyflyte/default_arguments/map_wf.py | 13 + .../flytekit/unit/cli/pyflyte/imageSpec.yaml | 2 + .../unit/cli/pyflyte/image_spec_wf.py | 20 + .../unit/cli/pyflyte/imperative_wf.py | 39 + .../unit/cli/pyflyte/test_backfill.py | 45 + .../flytekit/unit/cli/pyflyte/test_build.py | 27 + .../flytekit/unit/cli/pyflyte/test_init.py | 26 + .../unit/cli/pyflyte/test_launchplan.py | 34 + .../flytekit/unit/cli/pyflyte/test_main.py | 34 + .../pyflyte/test_nested_wf/a/b/c/__init__.py | 0 .../test_nested_wf/a/b/c/d/__init__.py | 0 .../cli/pyflyte/test_nested_wf/a/b/c/d/wf.py | 11 + .../flytekit/unit/cli/pyflyte/test_package.py | 209 + .../flytekit/unit/cli/pyflyte/test_plugin.py | 74 + .../unit/cli/pyflyte/test_register.py | 162 + .../flytekit/unit/cli/pyflyte/test_run.py | 432 + .../flytekit/unit/cli/pyflyte/test_serve.py | 9 + .../unit/cli/pyflyte/testdata/df.parquet | Bin 0 -> 2212 bytes .../flytekit/unit/cli/pyflyte/workflow.py | 127 + .../flytekit/unit/cli/test_cli_helpers.py | 428 + .../tests/flytekit/unit/cli/test_flyte_cli.py | 70 + .../tests/flytekit/unit/clients/__init__.py | 0 .../flytekit/unit/clients/auth/__init__.py | 0 .../unit/clients/auth/test_auth_client.py | 42 + .../unit/clients/auth/test_authenticator.py | 145 + .../unit/clients/auth/test_default_html.py | 33 + .../unit/clients/auth/test_keyring_store.py | 32 + .../unit/clients/auth/test_token_client.py | 86 + .../unit/clients/auth_deprecation.config | 3 + .../unit/clients/auth_deprecation2.config | 2 + .../unit/clients/auth_deprecation3.config | 2 + .../flytekit/unit/clients/test_auth_helper.py | 199 + .../flytekit/unit/clients/test_friendly.py | 38 + .../tests/flytekit/unit/clients/test_raw.py | 24 + .../protos/CompiledWorkflowClosure.pb | Bin 0 -> 2118 bytes .../resources/protos/OneTaskWFForPromote.pb | Bin 0 -> 546 bytes .../common_tests/test_workflow_promote.py | 110 + .../flytekit/unit/configuration/__init__.py | 0 .../unit/configuration/configs/bad.config | 1 + .../configs/creds_secret_env_var.yaml | 13 + .../configs/creds_secret_location.yaml | 14 + .../unit/configuration/configs/fake_secret | 1 + .../unit/configuration/configs/good.config | 31 + .../unit/configuration/configs/images.config | 14 + .../unit/configuration/configs/no_images.yaml | 13 + .../unit/configuration/configs/nossl.yaml | 4 + .../unit/configuration/configs/sample.yaml | 16 + .../unit/configuration/configs/sample.yml | 13 + .../flytekit/unit/configuration/test_file.py | 146 + .../unit/configuration/test_image_config.py | 78 + .../unit/configuration/test_internal.py | 103 + .../unit/configuration/test_yaml_file.py | 78 + flytekit/tests/flytekit/unit/conftest.py | 13 + flytekit/tests/flytekit/unit/core/__init__.py | 0 .../flytekit/unit/core/configs/images.config | 4 + .../unit/core/flyte_functools/__init__.py | 0 .../core/flyte_functools/decorator_source.py | 24 + .../core/flyte_functools/decorator_usage.py | 9 + .../core/flyte_functools/nested_function.py | 35 + .../nested_wrapped_function.py | 36 + .../core/flyte_functools/simple_decorator.py | 41 + .../flyte_functools/stacked_decorators.py | 51 + .../test_decorator_location.py | 17 + .../core/flyte_functools/test_decorators.py | 97 + .../flyte_functools/unwrapped_decorator.py | 29 + .../flytekit/unit/core/image_spec/__init__.py | 0 .../unit/core/image_spec/registry_config.json | 20 + .../unit/core/image_spec/requirements.txt | 1 + .../unit/core/image_spec/test_image_spec.py | 99 + flytekit/tests/flytekit/unit/core/tasks.py | 11 + .../unit/core/test_are_types_castable.py | 138 + .../unit/core/test_array_node_map_task.py | 308 + .../flytekit/unit/core/test_artifacts.py | 335 + .../flytekit/unit/core/test_checkpoint.py | 157 + .../flytekit/unit/core/test_checkpointer.py | 67 + .../unit/core/test_complex_nesting.py | 223 + .../flytekit/unit/core/test_composition.py | 200 + .../flytekit/unit/core/test_conditions.py | 497 + .../flytekit/unit/core/test_container_task.py | 181 + .../unit/core/test_context_manager.py | 262 + .../tests/flytekit/unit/core/test_data.py | 420 + .../unit/core/test_data_persistence.py | 149 + .../flytekit/unit/core/test_dataclass.py | 31 + .../flytekit/unit/core/test_docstring.py | 95 + .../tests/flytekit/unit/core/test_dynamic.py | 292 + .../unit/core/test_dynamic_conditional.py | 100 + .../unit/core/test_flyte_directory.py | 316 + .../flytekit/unit/core/test_flyte_file.py | 642 + .../flytekit/unit/core/test_flyte_typing.py | 29 + .../tests/flytekit/unit/core/test_gate.py | 325 + .../flytekit/unit/core/test_imperative.py | 376 + .../core/test_imperative_with_patching.py | 69 + .../flytekit/unit/core/test_interface.py | 378 + .../flytekit/unit/core/test_launch_plan.py | 434 + .../unit/core/test_literals_resolver.py | 136 + .../flytekit/unit/core/test_local_cache.py | 581 + .../tests/flytekit/unit/core/test_map_task.py | 363 + .../flytekit/unit/core/test_node_creation.py | 517 + .../flytekit/unit/core/test_notifications.py | 65 + .../tests/flytekit/unit/core/test_numpy.py | 61 + .../tests/flytekit/unit/core/test_partials.py | 256 + .../tests/flytekit/unit/core/test_promise.py | 228 + .../tests/flytekit/unit/core/test_protobuf.py | 63 + .../unit/core/test_python_auto_container.py | 351 + .../unit/core/test_python_function_task.py | 226 + .../unit/core/test_realworld_examples.py | 180 + .../flytekit/unit/core/test_references.py | 492 + .../tests/flytekit/unit/core/test_resolver.py | 106 + .../flytekit/unit/core/test_resources.py | 77 + .../tests/flytekit/unit/core/test_schedule.py | 153 + .../flytekit/unit/core/test_serialization.py | 477 + .../flytekit/unit/core/test_shim_task.py | 70 + .../tests/flytekit/unit/core/test_signal.py | 48 + .../unit/core/test_type_conversion_errors.py | 127 + .../flytekit/unit/core/test_type_delayed.py | 43 + .../flytekit/unit/core/test_type_engine.py | 2541 ++++ .../flytekit/unit/core/test_type_hints.py | 2014 ++++ .../unit/core/test_typing_annotation.py | 62 + .../tests/flytekit/unit/core/test_utils.py | 104 + .../flytekit/unit/core/test_workflows.py | 437 + .../tests/flytekit/unit/core/test_wrapping.py | 58 + .../flytekit/unit/core/tracker/__init__.py | 0 .../tests/flytekit/unit/core/tracker/a.py | 5 + .../tests/flytekit/unit/core/tracker/b.py | 14 + .../tests/flytekit/unit/core/tracker/c.py | 5 + .../tests/flytekit/unit/core/tracker/d.py | 15 + .../unit/core/tracker/test_tracking.py | 88 + .../tests/flytekit/unit/deck/test_deck.py | 155 + .../tests/flytekit/unit/deck/test_renderer.py | 37 + .../flytekit/unit/exceptions/__init__.py | 0 .../flytekit/unit/exceptions/test_base.py | 10 + .../flytekit/unit/exceptions/test_scopes.py | 118 + .../flytekit/unit/exceptions/test_system.py | 71 + .../flytekit/unit/exceptions/test_user.py | 83 + .../flytekit/unit/experimental/__init__.py | 0 .../unit/experimental/test_eager_workflows.py | 277 + .../tests/flytekit/unit/extend/test_agent.py | 305 + .../tests/flytekit/unit/extras/__init__.py | 0 .../flytekit/unit/extras/pytorch/__init__.py | 0 .../unit/extras/pytorch/test_checkpoint.py | 104 + .../unit/extras/pytorch/test_native.py | 73 + .../extras/pytorch/test_transformations.py | 132 + .../flytekit/unit/extras/sklearn/__init__.py | 0 .../unit/extras/sklearn/test_native.py | 48 + .../extras/sklearn/test_transformations.py | 99 + .../flytekit/unit/extras/sqlite3/__init__.py | 0 .../flytekit/unit/extras/sqlite3/chinook.zip | Bin 0 -> 305596 bytes .../unit/extras/sqlite3/test_sql_tracker.py | 29 + .../flytekit/unit/extras/sqlite3/test_task.py | 150 + .../flytekit/unit/extras/tasks/__init__.py | 0 .../flytekit/unit/extras/tasks/test_shell.py | 356 + .../extras/tasks/testdata/long-running.sh | 7 + .../unit/extras/tasks/testdata/script.exe | 2 + .../unit/extras/tasks/testdata/script.sh | 6 + .../extras/tasks/testdata/script_args_env.sh | 22 + .../unit/extras/tasks/testdata/test.csv | 4 + .../unit/extras/tensorflow/__init__.py | 0 .../unit/extras/tensorflow/model/__init__.py | 0 .../extras/tensorflow/model/test_model.py | 54 + .../tensorflow/model/test_transformations.py | 75 + .../unit/extras/tensorflow/record/__init__.py | 0 .../extras/tensorflow/record/test_record.py | 115 + .../tensorflow/record/test_transformations.py | 89 + .../flytekit/unit/extras/test_accelerators.py | 64 + .../flytekit/unit/interaction/__init__.py | 0 .../unit/interaction/test_click_types.py | 165 + .../unit/interaction/test_string_literals.py | 114 + .../flytekit/unit/interfaces/__init__.py | 0 .../flytekit/unit/interfaces/test_random.py | 17 + .../flytekit/unit/lazy_module/__init__.py | 0 .../unit/lazy_module/test_lazy_module.py | 12 + .../tests/flytekit/unit/models/__init__.py | 0 .../flytekit/unit/models/admin/__init__.py | 0 .../flytekit/unit/models/admin/test_common.py | 30 + .../unit/models/admin/test_node_executions.py | 51 + .../flytekit/unit/models/core/__init__.py | 0 .../flytekit/unit/models/core/test_catalog.py | 32 + .../flytekit/unit/models/core/test_errors.py | 30 + .../unit/models/core/test_execution.py | 33 + .../unit/models/core/test_identifier.py | 72 + .../unit/models/core/test_security.py | 26 + .../flytekit/unit/models/core/test_types.py | 39 + .../unit/models/core/test_workflow.py | 334 + .../tests/flytekit/unit/models/test_common.py | 105 + .../unit/models/test_documentation.py | 29 + .../flytekit/unit/models/test_dynamic_job.py | 61 + .../flytekit/unit/models/test_execution.py | 281 + .../flytekit/unit/models/test_filters.py | 39 + .../flytekit/unit/models/test_interface.py | 69 + .../flytekit/unit/models/test_launch_plan.py | 129 + .../flytekit/unit/models/test_literals.py | 533 + .../unit/models/test_matchable_resource.py | 68 + .../flytekit/unit/models/test_named_entity.py | 13 + .../flytekit/unit/models/test_project.py | 24 + .../tests/flytekit/unit/models/test_qubole.py | 15 + .../flytekit/unit/models/test_schedule.py | 55 + .../tests/flytekit/unit/models/test_tasks.py | 359 + .../tests/flytekit/unit/models/test_types.py | 121 + .../unit/models/test_workflow_closure.py | 103 + .../tests/flytekit/unit/remote/__init__.py | 0 .../tests/flytekit/unit/remote/resources.py | 12 + .../responses/CompiledWorkflowClosure.pb | Bin 0 -> 2118 bytes ...re.control_flow.subworkflows.leaf_subwf.pb | Bin 0 -> 330 bytes .../remote/responses/admin.task_pb2.Task.pb | Bin 0 -> 1438 bytes ...control_flow.subworkflows.root_level_wf.pb | Bin 0 -> 3417 bytes .../flytekit/unit/remote/test_backfill.py | 95 + .../flytekit/unit/remote/test_calling.py | 186 + .../flytekit/unit/remote/test_fs_remote.py | 149 + .../flytekit/unit/remote/test_lazy_entity.py | 78 + .../tests/flytekit/unit/remote/test_mocked.py | 25 + .../tests/flytekit/unit/remote/test_remote.py | 535 + .../flytekit/unit/remote/test_sandbox.py | 101 + .../unit/remote/test_with_responses.py | 84 + .../unit/remote/test_wrapper_classes.py | 148 + .../flytekit/unit/remote/wf_with_remote.py | 17 + .../tests/flytekit/unit/sensor/__init__.py | 0 .../flytekit/unit/sensor/test_file_sensor.py | 31 + .../unit/sensor/test_sensor_engine.py | 47 + .../tests/flytekit/unit/test_translator.py | 168 + .../tests/flytekit/unit/tools/__init__.py | 0 .../unit/tools/test_fast_registration.py | 105 + .../tests/flytekit/unit/tools/test_ignore.py | 235 + .../flytekit/unit/tools/test_module_loader.py | 6 + .../tests/flytekit/unit/tools/test_repo.py | 70 + .../flytekit/unit/tools/test_script_mode.py | 100 + .../flytekit/unit/tools/test_subprocess.py | 33 + .../flytekit/unit/types/directory/__init__.py | 0 .../unit/types/directory/test_types.py | 31 + .../flytekit/unit/types/error/__init__.py | 0 .../flytekit/unit/types/error/test_error.py | 31 + .../flytekit/unit/types/file/__init__.py | 0 .../flytekit/unit/types/file/test_image.py | 22 + .../unit/types/iterator/test_iterator.py | 23 + .../flytekit/unit/types/numpy/test_ndarray.py | 83 + .../unit/types/pickle/test_flyte_pickle.py | 110 + .../unit/types/schema/test_schema_types.py | 66 + .../structured_dataset/test_arrow_data.py | 32 + .../types/structured_dataset/test_bigquery.py | 51 + .../test_structured_dataset.py | 510 + .../test_structured_dataset_handlers.py | 114 + .../test_structured_dataset_workflow.py | 264 + 932 files changed, 129016 insertions(+) create mode 100644 flytekit/.github/config.yml create mode 100644 flytekit/.github/workflows/build_image.yml create mode 100644 flytekit/.github/workflows/monodocs_build.yml create mode 100644 flytekit/.github/workflows/pythonbuild.yml create mode 100644 flytekit/.github/workflows/pythonpublish.yml create mode 100644 flytekit/.github/workflows/upgrade_automation.yml create mode 100644 flytekit/.gitignore create mode 100644 flytekit/.pre-commit-config.yaml create mode 100644 flytekit/.readthedocs.yml create mode 100644 flytekit/CODEOWNERS create mode 100644 flytekit/CODE_OF_CONDUCT.md create mode 100644 flytekit/Dockerfile create mode 100644 flytekit/Dockerfile.agent create mode 100644 flytekit/Dockerfile.dev create mode 100644 flytekit/LICENSE create mode 100644 flytekit/MANIFEST.in create mode 100644 flytekit/Makefile create mode 100644 flytekit/NOTICE create mode 100644 flytekit/README.md create mode 100644 flytekit/codecov.yml create mode 100644 flytekit/dev-requirements.in create mode 100644 flytekit/dev-requirements.txt create mode 100644 flytekit/docs/Makefile create mode 100644 flytekit/docs/make.bat create mode 100644 flytekit/docs/source/_templates/custom.rst create mode 100644 flytekit/docs/source/_templates/file_types.rst create mode 100644 flytekit/docs/source/_templates/sidebar/brand.html create mode 100644 flytekit/docs/source/clients.rst create mode 100644 flytekit/docs/source/conf.py create mode 100644 flytekit/docs/source/configuration.rst create mode 100644 flytekit/docs/source/contributing.rst create mode 100644 flytekit/docs/source/deck.rst create mode 100644 flytekit/docs/source/design/authoring.rst create mode 100644 flytekit/docs/source/design/clis.rst create mode 100644 flytekit/docs/source/design/control_plane.rst create mode 100644 flytekit/docs/source/design/execution.rst create mode 100644 flytekit/docs/source/design/index.rst create mode 100644 flytekit/docs/source/design/models.rst create mode 100644 flytekit/docs/source/docs_index.rst create mode 100644 flytekit/docs/source/experimental.rst create mode 100644 flytekit/docs/source/extend.rst create mode 100644 flytekit/docs/source/extras.accelerators.rst create mode 100644 flytekit/docs/source/extras.pytorch.rst create mode 100644 flytekit/docs/source/extras.sklearn.rst create mode 100644 flytekit/docs/source/extras.sqlite3.rst create mode 100644 flytekit/docs/source/extras.tasks.rst create mode 100644 flytekit/docs/source/extras.tensorflow.rst create mode 100644 flytekit/docs/source/flyte_circle_gradient_1_4x4.png create mode 100644 flytekit/docs/source/flytekit.rst create mode 100644 flytekit/docs/source/index.rst create mode 100644 flytekit/docs/source/plugins/athena.rst create mode 100644 flytekit/docs/source/plugins/awsbatch.rst create mode 100644 flytekit/docs/source/plugins/awssagemaker.rst create mode 100644 flytekit/docs/source/plugins/bigquery.rst create mode 100644 flytekit/docs/source/plugins/dask.rst create mode 100644 flytekit/docs/source/plugins/dbt.rst create mode 100644 flytekit/docs/source/plugins/deck.rst create mode 100644 flytekit/docs/source/plugins/dolt.rst create mode 100644 flytekit/docs/source/plugins/duckdb.rst create mode 100644 flytekit/docs/source/plugins/fsspec.rst create mode 100644 flytekit/docs/source/plugins/greatexpectations.rst create mode 100644 flytekit/docs/source/plugins/hive.rst create mode 100644 flytekit/docs/source/plugins/index.rst create mode 100644 flytekit/docs/source/plugins/kfmpi.rst create mode 100644 flytekit/docs/source/plugins/kfpytorch.rst create mode 100644 flytekit/docs/source/plugins/kftensorflow.rst create mode 100644 flytekit/docs/source/plugins/mlflow.rst create mode 100644 flytekit/docs/source/plugins/modin.rst create mode 100644 flytekit/docs/source/plugins/onnxpytorch.rst create mode 100644 flytekit/docs/source/plugins/onnxscikitlearn.rst create mode 100644 flytekit/docs/source/plugins/onnxtensorflow.rst create mode 100644 flytekit/docs/source/plugins/pandera.rst create mode 100644 flytekit/docs/source/plugins/papermill.rst create mode 100644 flytekit/docs/source/plugins/pod.rst create mode 100644 flytekit/docs/source/plugins/ray.rst create mode 100644 flytekit/docs/source/plugins/snowflake.rst create mode 100644 flytekit/docs/source/plugins/spark.rst create mode 100644 flytekit/docs/source/plugins/sqlalchemy.rst create mode 100644 flytekit/docs/source/plugins/vaex.rst create mode 100644 flytekit/docs/source/pyflyte.rst create mode 100644 flytekit/docs/source/remote.rst create mode 100644 flytekit/docs/source/tasks.extend.rst create mode 100644 flytekit/docs/source/testing.rst create mode 100644 flytekit/docs/source/types.builtins.directory.rst create mode 100644 flytekit/docs/source/types.builtins.file.rst create mode 100644 flytekit/docs/source/types.builtins.structured.rst create mode 100644 flytekit/docs/source/types.extend.rst create mode 100644 flytekit/flytekit/__init__.py create mode 100644 flytekit/flytekit/clients/__init__.py create mode 100644 flytekit/flytekit/clients/auth/__init__.py create mode 100644 flytekit/flytekit/clients/auth/auth_client.py create mode 100644 flytekit/flytekit/clients/auth/authenticator.py create mode 100644 flytekit/flytekit/clients/auth/default_html.py create mode 100644 flytekit/flytekit/clients/auth/exceptions.py create mode 100644 flytekit/flytekit/clients/auth/keyring.py create mode 100644 flytekit/flytekit/clients/auth/token_client.py create mode 100644 flytekit/flytekit/clients/auth_helper.py create mode 100644 flytekit/flytekit/clients/friendly.py create mode 100644 flytekit/flytekit/clients/grpc_utils/__init__.py create mode 100644 flytekit/flytekit/clients/grpc_utils/auth_interceptor.py create mode 100644 flytekit/flytekit/clients/grpc_utils/default_metadata_interceptor.py create mode 100644 flytekit/flytekit/clients/grpc_utils/wrap_exception_interceptor.py create mode 100644 flytekit/flytekit/clients/helpers.py create mode 100644 flytekit/flytekit/clients/raw.py create mode 100644 flytekit/flytekit/clis/__init__.py create mode 100644 flytekit/flytekit/clis/flyte_cli/__init__.py create mode 100644 flytekit/flytekit/clis/flyte_cli/example.config create mode 100644 flytekit/flytekit/clis/flyte_cli/main.py create mode 100644 flytekit/flytekit/clis/helpers.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/__init__.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/backfill.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/build.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/constants.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/fetch.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/get.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/helpers.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/init.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/launchplan.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/local_cache.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/metrics.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/package.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/pyflyte.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/register.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/run.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/serialize.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/serve.py create mode 100644 flytekit/flytekit/clis/sdk_in_container/utils.py create mode 100644 flytekit/flytekit/clis/version.py create mode 100644 flytekit/flytekit/configuration/__init__.py create mode 100644 flytekit/flytekit/configuration/default_images.py create mode 100644 flytekit/flytekit/configuration/feature_flags.py create mode 100644 flytekit/flytekit/configuration/file.py create mode 100644 flytekit/flytekit/configuration/internal.py create mode 100644 flytekit/flytekit/configuration/plugin.py create mode 100644 flytekit/flytekit/core/__init__.py create mode 100644 flytekit/flytekit/core/annotation.py create mode 100644 flytekit/flytekit/core/array_node_map_task.py create mode 100644 flytekit/flytekit/core/artifact.py create mode 100644 flytekit/flytekit/core/base_sql_task.py create mode 100644 flytekit/flytekit/core/base_task.py create mode 100644 flytekit/flytekit/core/checkpointer.py create mode 100644 flytekit/flytekit/core/class_based_resolver.py create mode 100644 flytekit/flytekit/core/condition.py create mode 100644 flytekit/flytekit/core/constants.py create mode 100644 flytekit/flytekit/core/container_task.py create mode 100644 flytekit/flytekit/core/context_manager.py create mode 100644 flytekit/flytekit/core/data_persistence.py create mode 100644 flytekit/flytekit/core/docstring.py create mode 100644 flytekit/flytekit/core/dynamic_workflow_task.py create mode 100644 flytekit/flytekit/core/gate.py create mode 100644 flytekit/flytekit/core/hash.py create mode 100644 flytekit/flytekit/core/interface.py create mode 100644 flytekit/flytekit/core/launch_plan.py create mode 100644 flytekit/flytekit/core/local_cache.py create mode 100644 flytekit/flytekit/core/local_fsspec.py create mode 100644 flytekit/flytekit/core/map_task.py create mode 100644 flytekit/flytekit/core/mock_stats.py create mode 100644 flytekit/flytekit/core/node.py create mode 100644 flytekit/flytekit/core/node_creation.py create mode 100644 flytekit/flytekit/core/notification.py create mode 100644 flytekit/flytekit/core/pod_template.py create mode 100644 flytekit/flytekit/core/promise.py create mode 100644 flytekit/flytekit/core/python_auto_container.py create mode 100644 flytekit/flytekit/core/python_customized_container_task.py create mode 100644 flytekit/flytekit/core/python_function_task.py create mode 100644 flytekit/flytekit/core/reference.py create mode 100644 flytekit/flytekit/core/reference_entity.py create mode 100644 flytekit/flytekit/core/resources.py create mode 100644 flytekit/flytekit/core/schedule.py create mode 100644 flytekit/flytekit/core/shim_task.py create mode 100644 flytekit/flytekit/core/task.py create mode 100644 flytekit/flytekit/core/testing.py create mode 100644 flytekit/flytekit/core/tracked_abc.py create mode 100644 flytekit/flytekit/core/tracker.py create mode 100644 flytekit/flytekit/core/type_engine.py create mode 100644 flytekit/flytekit/core/type_helpers.py create mode 100644 flytekit/flytekit/core/utils.py create mode 100644 flytekit/flytekit/core/workflow.py create mode 100644 flytekit/flytekit/deck/__init__.py create mode 100644 flytekit/flytekit/deck/deck.py create mode 100644 flytekit/flytekit/deck/html/__init__.py create mode 100644 flytekit/flytekit/deck/html/template.html create mode 100644 flytekit/flytekit/deck/renderer.py create mode 100644 flytekit/flytekit/exceptions/__init__.py create mode 100644 flytekit/flytekit/exceptions/base.py create mode 100644 flytekit/flytekit/exceptions/scopes.py create mode 100644 flytekit/flytekit/exceptions/system.py create mode 100644 flytekit/flytekit/exceptions/user.py create mode 100644 flytekit/flytekit/experimental/__init__.py create mode 100644 flytekit/flytekit/experimental/eager_function.py create mode 100644 flytekit/flytekit/extend/__init__.py create mode 100644 flytekit/flytekit/extend/backend/__init__.py create mode 100644 flytekit/flytekit/extend/backend/agent_service.py create mode 100644 flytekit/flytekit/extend/backend/base_agent.py create mode 100644 flytekit/flytekit/extras/__init__.py create mode 100644 flytekit/flytekit/extras/accelerators.py create mode 100644 flytekit/flytekit/extras/cloud_pickle_resolver.py create mode 100644 flytekit/flytekit/extras/pytorch/__init__.py create mode 100644 flytekit/flytekit/extras/pytorch/checkpoint.py create mode 100644 flytekit/flytekit/extras/pytorch/native.py create mode 100644 flytekit/flytekit/extras/sklearn/__init__.py create mode 100644 flytekit/flytekit/extras/sklearn/native.py create mode 100644 flytekit/flytekit/extras/sqlite3/__init__.py create mode 100644 flytekit/flytekit/extras/sqlite3/task.py create mode 100644 flytekit/flytekit/extras/tasks/__init__.py create mode 100644 flytekit/flytekit/extras/tasks/shell.py create mode 100644 flytekit/flytekit/extras/tensorflow/__init__.py create mode 100644 flytekit/flytekit/extras/tensorflow/model.py create mode 100644 flytekit/flytekit/extras/tensorflow/record.py create mode 100644 flytekit/flytekit/image_spec/__init__.py create mode 100644 flytekit/flytekit/image_spec/image_spec.py create mode 100644 flytekit/flytekit/interaction/__init__.py create mode 100644 flytekit/flytekit/interaction/click_types.py create mode 100644 flytekit/flytekit/interaction/parse_stdin.py create mode 100644 flytekit/flytekit/interaction/rich_utils.py create mode 100644 flytekit/flytekit/interaction/string_literals.py create mode 100644 flytekit/flytekit/interfaces/__init__.py create mode 100644 flytekit/flytekit/interfaces/cli_identifiers.py create mode 100644 flytekit/flytekit/interfaces/random.py create mode 100644 flytekit/flytekit/interfaces/stats/__init__.py create mode 100644 flytekit/flytekit/interfaces/stats/client.py create mode 100644 flytekit/flytekit/interfaces/stats/taggable.py create mode 100644 flytekit/flytekit/lazy_import/__init__.py create mode 100644 flytekit/flytekit/lazy_import/lazy_module.py create mode 100644 flytekit/flytekit/loggers.py create mode 100644 flytekit/flytekit/models/__init__.py create mode 100644 flytekit/flytekit/models/admin/__init__.py create mode 100644 flytekit/flytekit/models/admin/common.py create mode 100644 flytekit/flytekit/models/admin/task_execution.py create mode 100644 flytekit/flytekit/models/admin/workflow.py create mode 100644 flytekit/flytekit/models/annotation.py create mode 100644 flytekit/flytekit/models/array_job.py create mode 100644 flytekit/flytekit/models/common.py create mode 100644 flytekit/flytekit/models/core/__init__.py create mode 100644 flytekit/flytekit/models/core/catalog.py create mode 100644 flytekit/flytekit/models/core/compiler.py create mode 100644 flytekit/flytekit/models/core/condition.py create mode 100644 flytekit/flytekit/models/core/errors.py create mode 100644 flytekit/flytekit/models/core/execution.py create mode 100644 flytekit/flytekit/models/core/identifier.py create mode 100644 flytekit/flytekit/models/core/types.py create mode 100644 flytekit/flytekit/models/core/workflow.py create mode 100644 flytekit/flytekit/models/documentation.py create mode 100644 flytekit/flytekit/models/dynamic_job.py create mode 100644 flytekit/flytekit/models/execution.py create mode 100644 flytekit/flytekit/models/filters.py create mode 100644 flytekit/flytekit/models/interface.py create mode 100644 flytekit/flytekit/models/launch_plan.py create mode 100644 flytekit/flytekit/models/literals.py create mode 100644 flytekit/flytekit/models/matchable_resource.py create mode 100644 flytekit/flytekit/models/named_entity.py create mode 100644 flytekit/flytekit/models/node_execution.py create mode 100644 flytekit/flytekit/models/presto.py create mode 100644 flytekit/flytekit/models/project.py create mode 100644 flytekit/flytekit/models/qubole.py create mode 100644 flytekit/flytekit/models/schedule.py create mode 100644 flytekit/flytekit/models/security.py create mode 100644 flytekit/flytekit/models/task.py create mode 100644 flytekit/flytekit/models/types.py create mode 100644 flytekit/flytekit/models/workflow_closure.py create mode 100644 flytekit/flytekit/remote/__init__.py create mode 100644 flytekit/flytekit/remote/backfill.py create mode 100644 flytekit/flytekit/remote/data.py create mode 100644 flytekit/flytekit/remote/entities.py create mode 100644 flytekit/flytekit/remote/executions.py create mode 100644 flytekit/flytekit/remote/interface.py create mode 100644 flytekit/flytekit/remote/lazy_entity.py create mode 100644 flytekit/flytekit/remote/remote.py create mode 100644 flytekit/flytekit/remote/remote_callable.py create mode 100644 flytekit/flytekit/remote/remote_fs.py create mode 100644 flytekit/flytekit/sensor/__init__.py create mode 100644 flytekit/flytekit/sensor/base_sensor.py create mode 100644 flytekit/flytekit/sensor/file_sensor.py create mode 100644 flytekit/flytekit/sensor/sensor_engine.py create mode 100644 flytekit/flytekit/testing/__init__.py create mode 100644 flytekit/flytekit/tools/__init__.py create mode 100644 flytekit/flytekit/tools/fast_registration.py create mode 100644 flytekit/flytekit/tools/ignore.py create mode 100644 flytekit/flytekit/tools/interactive.py create mode 100644 flytekit/flytekit/tools/module_loader.py create mode 100644 flytekit/flytekit/tools/repo.py create mode 100644 flytekit/flytekit/tools/script_mode.py create mode 100644 flytekit/flytekit/tools/serialize_helpers.py create mode 100644 flytekit/flytekit/tools/subprocess.py create mode 100644 flytekit/flytekit/tools/translator.py create mode 100644 flytekit/flytekit/types/__init__.py create mode 100644 flytekit/flytekit/types/directory/__init__.py create mode 100644 flytekit/flytekit/types/directory/types.py create mode 100644 flytekit/flytekit/types/error/__init__.py create mode 100644 flytekit/flytekit/types/error/error.py create mode 100644 flytekit/flytekit/types/file/__init__.py create mode 100644 flytekit/flytekit/types/file/file.py create mode 100644 flytekit/flytekit/types/file/image.py create mode 100644 flytekit/flytekit/types/iterator/__init__.py create mode 100644 flytekit/flytekit/types/iterator/iterator.py create mode 100644 flytekit/flytekit/types/numpy/__init__.py create mode 100644 flytekit/flytekit/types/numpy/ndarray.py create mode 100644 flytekit/flytekit/types/pickle/__init__.py create mode 100644 flytekit/flytekit/types/pickle/pickle.py create mode 100644 flytekit/flytekit/types/schema/__init__.py create mode 100644 flytekit/flytekit/types/schema/types.py create mode 100644 flytekit/flytekit/types/schema/types_pandas.py create mode 100644 flytekit/flytekit/types/structured/__init__.py create mode 100644 flytekit/flytekit/types/structured/basic_dfs.py create mode 100644 flytekit/flytekit/types/structured/bigquery.py create mode 100644 flytekit/flytekit/types/structured/structured_dataset.py create mode 100644 flytekit/flytekit_scripts/Readme.rst create mode 100755 flytekit/flytekit_scripts/flytekit_build_image.sh create mode 100644 flytekit/flytekit_scripts/flytekit_venv create mode 100644 flytekit/plugins/Makefile create mode 100644 flytekit/plugins/README.md create mode 100644 flytekit/plugins/__init__.py create mode 100644 flytekit/plugins/flytekit-airflow/README.md create mode 100644 flytekit/plugins/flytekit-airflow/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-airflow/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/__init__.py create mode 100644 flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/agent.py create mode 100644 flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/task.py create mode 100644 flytekit/plugins/flytekit-airflow/setup.py create mode 100644 flytekit/plugins/flytekit-airflow/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-airflow/tests/test_agent.py create mode 100644 flytekit/plugins/flytekit-airflow/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/README.md create mode 100644 flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/__init__.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/__init__.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/constants.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/s3fs.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/setup.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-async-fsspec/tests/test_s3fs.py create mode 100644 flytekit/plugins/flytekit-aws-athena/README.md create mode 100644 flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/task.py create mode 100644 flytekit/plugins/flytekit-aws-athena/setup.py create mode 100644 flytekit/plugins/flytekit-aws-athena/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-athena/tests/test_athena.py create mode 100644 flytekit/plugins/flytekit-aws-batch/README.md create mode 100644 flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/task.py create mode 100644 flytekit/plugins/flytekit-aws-batch/setup.py create mode 100644 flytekit/plugins/flytekit-aws-batch/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-batch/tests/test_aws_batch.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/README.md create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/distributed_training.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/hpo.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/hpo_job.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/parameter_ranges.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/training_job.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/training.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/scripts/flytekit_sagemaker_runner.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/setup.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/test_flytekit_sagemaker_running.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo_job.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/test_parameter_ranges.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/test_training.py create mode 100644 flytekit/plugins/flytekit-aws-sagemaker/tests/test_training_job.py create mode 100644 flytekit/plugins/flytekit-bigquery/README.md create mode 100644 flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py create mode 100644 flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/agent.py create mode 100644 flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py create mode 100644 flytekit/plugins/flytekit-bigquery/setup.py create mode 100644 flytekit/plugins/flytekit-bigquery/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-bigquery/tests/test_agent.py create mode 100644 flytekit/plugins/flytekit-bigquery/tests/test_bigquery.py create mode 100644 flytekit/plugins/flytekit-dask/README.md create mode 100644 flytekit/plugins/flytekit-dask/flytekitplugins/dask/__init__.py create mode 100644 flytekit/plugins/flytekit-dask/flytekitplugins/dask/models.py create mode 100644 flytekit/plugins/flytekit-dask/flytekitplugins/dask/task.py create mode 100644 flytekit/plugins/flytekit-dask/setup.py create mode 100644 flytekit/plugins/flytekit-dask/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-dask/tests/test_models.py create mode 100644 flytekit/plugins/flytekit-dask/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-data-fsspec/README.md create mode 100644 flytekit/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py create mode 100644 flytekit/plugins/flytekit-data-fsspec/setup.py create mode 100644 flytekit/plugins/flytekit-data-fsspec/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-data-fsspec/tests/test_placeholder.py create mode 100644 flytekit/plugins/flytekit-dbt/README.md create mode 100644 flytekit/plugins/flytekit-dbt/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-dbt/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/__init__.py create mode 100644 flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/error.py create mode 100644 flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/schema.py create mode 100644 flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/task.py create mode 100644 flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/util.py create mode 100644 flytekit/plugins/flytekit-dbt/setup.py create mode 100644 flytekit/plugins/flytekit-dbt/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-dbt/tests/test_schema.py create mode 100644 flytekit/plugins/flytekit-dbt/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/.gitignore create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/LICENSE create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/README.md create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/dbt_project.yml create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/dbdiagram_definition.txt create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/jaffle_shop_erd.png create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/customers.sql create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/docs.md create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/orders.sql create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/overview.md create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/schema.yml create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/schema.yml create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_customers.sql create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_orders.sql create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_payments.sql create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/.gitkeep create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_customers.csv create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_orders.csv create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_payments.csv create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/tests/assert_total_payment_amount_is_positive.sql create mode 100644 flytekit/plugins/flytekit-dbt/tests/testdata/profiles/profiles.yml create mode 100644 flytekit/plugins/flytekit-deck-standard/README.md create mode 100644 flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/__init__.py create mode 100644 flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py create mode 100644 flytekit/plugins/flytekit-deck-standard/setup.py create mode 100644 flytekit/plugins/flytekit-deck-standard/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-deck-standard/tests/test_renderer.py create mode 100644 flytekit/plugins/flytekit-dolt/README.md create mode 100644 flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/__init__.py create mode 100644 flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/schema.py create mode 100644 flytekit/plugins/flytekit-dolt/scripts/flytekit_install_dolt.sh create mode 100755 flytekit/plugins/flytekit-dolt/scripts/install.sh create mode 100644 flytekit/plugins/flytekit-dolt/setup.py create mode 100644 flytekit/plugins/flytekit-dolt/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-dolt/tests/test_schema.py create mode 100644 flytekit/plugins/flytekit-dolt/tests/test_wf.py create mode 100644 flytekit/plugins/flytekit-duckdb/README.md create mode 100644 flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py create mode 100644 flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py create mode 100644 flytekit/plugins/flytekit-duckdb/setup.py create mode 100644 flytekit/plugins/flytekit-duckdb/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-envd/README.md create mode 100644 flytekit/plugins/flytekit-envd/flytekitplugins/envd/__init__.py create mode 100644 flytekit/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py create mode 100644 flytekit/plugins/flytekit-envd/setup.py create mode 100644 flytekit/plugins/flytekit-envd/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-envd/tests/test_image_spec.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/Dockerfile create mode 100644 flytekit/plugins/flytekit-flyteinteractive/README.md create mode 100644 flytekit/plugins/flytekit-flyteinteractive/docs/example.png create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/__init__.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/__init__.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/constants.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/decorator.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/utils.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/__init__.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/config.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/constants.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/decorator.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/setup.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/tests/test_flyin_plugin.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/tests/test_utils.py create mode 100644 flytekit/plugins/flytekit-flyteinteractive/tests/testdata/inputs.pb create mode 100644 flytekit/plugins/flytekit-flyteinteractive/tests/testdata/task.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/README.md create mode 100644 flytekit/plugins/flytekit-greatexpectations/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-greatexpectations/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/__init__.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/schema.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/task.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/setup.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/data/movies.sqlite create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/data/yellow_tripdata_sample_2019-01.csv create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/data/yellow_tripdata_sample_2019-02.csv create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/.gitignore create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/expectations/sqlite/movies.json create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/expectations/test/demo.json create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/expectations/test/demo_pyspark.json create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/expectations/test1/demo.json create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/great_expectations.yml create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/great_expectations/plugins/custom_data_docs/styles/data_docs_custom_styles.css create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/test_schema.py create mode 100644 flytekit/plugins/flytekit-greatexpectations/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-hive/README.md create mode 100644 flytekit/plugins/flytekit-hive/flytekitplugins/hive/__init__.py create mode 100644 flytekit/plugins/flytekit-hive/flytekitplugins/hive/task.py create mode 100644 flytekit/plugins/flytekit-hive/setup.py create mode 100644 flytekit/plugins/flytekit-hive/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-hive/tests/test_hive_task.py create mode 100644 flytekit/plugins/flytekit-huggingface/README.md create mode 100644 flytekit/plugins/flytekit-huggingface/flytekitplugins/huggingface/__init__.py create mode 100644 flytekit/plugins/flytekit-huggingface/flytekitplugins/huggingface/sd_transformers.py create mode 100644 flytekit/plugins/flytekit-huggingface/setup.py create mode 100644 flytekit/plugins/flytekit-huggingface/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-huggingface/tests/test_huggingface_plugin_sd.py create mode 100644 flytekit/plugins/flytekit-identity-aware-proxy/README.md create mode 100644 flytekit/plugins/flytekit-identity-aware-proxy/flytekitplugins/identity_aware_proxy/__init__.py create mode 100644 flytekit/plugins/flytekit-identity-aware-proxy/flytekitplugins/identity_aware_proxy/cli.py create mode 100644 flytekit/plugins/flytekit-identity-aware-proxy/setup.py create mode 100644 flytekit/plugins/flytekit-identity-aware-proxy/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-identity-aware-proxy/tests/test_flytekitplugins_iap.py create mode 100644 flytekit/plugins/flytekit-k8s-pod/README.md create mode 100644 flytekit/plugins/flytekit-k8s-pod/flytekitplugins/pod/__init__.py create mode 100644 flytekit/plugins/flytekit-k8s-pod/flytekitplugins/pod/task.py create mode 100644 flytekit/plugins/flytekit-k8s-pod/setup.py create mode 100644 flytekit/plugins/flytekit-k8s-pod/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-k8s-pod/tests/test_pod.py create mode 100644 flytekit/plugins/flytekit-kf-mpi/README.md create mode 100644 flytekit/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/__init__.py create mode 100644 flytekit/plugins/flytekit-kf-mpi/flytekitplugins/kfmpi/task.py create mode 100644 flytekit/plugins/flytekit-kf-mpi/setup.py create mode 100644 flytekit/plugins/flytekit-kf-mpi/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-kf-mpi/tests/test_mpi_task.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/README.md create mode 100644 flytekit/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/__init__.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/error_handling.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/task.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/setup.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/tests/test_elastic_task.py create mode 100644 flytekit/plugins/flytekit-kf-pytorch/tests/test_pytorch_task.py create mode 100644 flytekit/plugins/flytekit-kf-tensorflow/README.md create mode 100644 flytekit/plugins/flytekit-kf-tensorflow/flytekitplugins/kftensorflow/__init__.py create mode 100644 flytekit/plugins/flytekit-kf-tensorflow/flytekitplugins/kftensorflow/task.py create mode 100644 flytekit/plugins/flytekit-kf-tensorflow/setup.py create mode 100644 flytekit/plugins/flytekit-kf-tensorflow/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-kf-tensorflow/tests/test_tensorflow_task.py create mode 100644 flytekit/plugins/flytekit-mlflow/README.md create mode 100644 flytekit/plugins/flytekit-mlflow/flytekitplugins/mlflow/__init__.py create mode 100644 flytekit/plugins/flytekit-mlflow/flytekitplugins/mlflow/tracking.py create mode 100644 flytekit/plugins/flytekit-mlflow/setup.py create mode 100644 flytekit/plugins/flytekit-mlflow/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-mlflow/tests/test_mlflow_tracking.py create mode 100644 flytekit/plugins/flytekit-mmcloud/README.md create mode 100644 flytekit/plugins/flytekit-mmcloud/flytekitplugins/mmcloud/__init__.py create mode 100644 flytekit/plugins/flytekit-mmcloud/flytekitplugins/mmcloud/agent.py create mode 100644 flytekit/plugins/flytekit-mmcloud/flytekitplugins/mmcloud/task.py create mode 100644 flytekit/plugins/flytekit-mmcloud/flytekitplugins/mmcloud/utils.py create mode 100644 flytekit/plugins/flytekit-mmcloud/setup.py create mode 100644 flytekit/plugins/flytekit-mmcloud/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-mmcloud/tests/test_mmcloud.py create mode 100644 flytekit/plugins/flytekit-modin/README.md create mode 100644 flytekit/plugins/flytekit-modin/flytekitplugins/modin/__init__.py create mode 100644 flytekit/plugins/flytekit-modin/flytekitplugins/modin/schema.py create mode 100644 flytekit/plugins/flytekit-modin/setup.py create mode 100644 flytekit/plugins/flytekit-modin/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-modin/tests/test_modin_plugin.py create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/README.md create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/flytekitplugins/onnxpytorch/__init__.py create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/flytekitplugins/onnxpytorch/schema.py create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/setup.py create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-onnx-pytorch/tests/test_onnx_pytorch.py create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/README.md create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/flytekitplugins/onnxscikitlearn/__init__.py create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/flytekitplugins/onnxscikitlearn/schema.py create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/setup.py create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-onnx-scikitlearn/tests/test_onnx_scikitlearn.py create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/README.md create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/flytekitplugins/onnxtensorflow/__init__.py create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/flytekitplugins/onnxtensorflow/schema.py create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/setup.py create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-onnx-tensorflow/tests/test_onnx_tf.py create mode 100644 flytekit/plugins/flytekit-pandera/README.md create mode 100644 flytekit/plugins/flytekit-pandera/flytekitplugins/pandera/__init__.py create mode 100644 flytekit/plugins/flytekit-pandera/flytekitplugins/pandera/schema.py create mode 100644 flytekit/plugins/flytekit-pandera/setup.py create mode 100644 flytekit/plugins/flytekit-pandera/tests/test_plugin.py create mode 100644 flytekit/plugins/flytekit-papermill/README.md create mode 100644 flytekit/plugins/flytekit-papermill/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-papermill/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-papermill/flytekitplugins/papermill/__init__.py create mode 100644 flytekit/plugins/flytekit-papermill/flytekitplugins/papermill/task.py create mode 100644 flytekit/plugins/flytekit-papermill/setup.py create mode 100644 flytekit/plugins/flytekit-papermill/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-papermill/tests/test_spark_notebook.py create mode 100644 flytekit/plugins/flytekit-papermill/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-papermill/tests/testdata/__init__.py create mode 100644 flytekit/plugins/flytekit-papermill/tests/testdata/datatype.py create mode 100644 flytekit/plugins/flytekit-polars/README.md create mode 100644 flytekit/plugins/flytekit-polars/flytekitplugins/polars/__init__.py create mode 100644 flytekit/plugins/flytekit-polars/flytekitplugins/polars/sd_transformers.py create mode 100644 flytekit/plugins/flytekit-polars/setup.py create mode 100644 flytekit/plugins/flytekit-polars/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-polars/tests/test_polars_plugin_sd.py create mode 100644 flytekit/plugins/flytekit-pydantic/README.md create mode 100644 flytekit/plugins/flytekit-pydantic/flytekitplugins/pydantic/__init__.py create mode 100644 flytekit/plugins/flytekit-pydantic/flytekitplugins/pydantic/basemodel_transformer.py create mode 100644 flytekit/plugins/flytekit-pydantic/flytekitplugins/pydantic/commons.py create mode 100644 flytekit/plugins/flytekit-pydantic/flytekitplugins/pydantic/deserialization.py create mode 100644 flytekit/plugins/flytekit-pydantic/flytekitplugins/pydantic/serialization.py create mode 100644 flytekit/plugins/flytekit-pydantic/setup.py create mode 100644 flytekit/plugins/flytekit-pydantic/tests/folder/test_file1.txt create mode 100644 flytekit/plugins/flytekit-pydantic/tests/folder/test_file2.txt create mode 100644 flytekit/plugins/flytekit-pydantic/tests/test_type_transformer.py create mode 100644 flytekit/plugins/flytekit-ray/README.md create mode 100644 flytekit/plugins/flytekit-ray/flytekitplugins/ray/__init__.py create mode 100644 flytekit/plugins/flytekit-ray/flytekitplugins/ray/models.py create mode 100644 flytekit/plugins/flytekit-ray/flytekitplugins/ray/task.py create mode 100644 flytekit/plugins/flytekit-ray/setup.py create mode 100644 flytekit/plugins/flytekit-ray/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-ray/tests/test_ray.py create mode 100644 flytekit/plugins/flytekit-snowflake/README.md create mode 100644 flytekit/plugins/flytekit-snowflake/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-snowflake/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-snowflake/flytekitplugins/snowflake/__init__.py create mode 100644 flytekit/plugins/flytekit-snowflake/flytekitplugins/snowflake/agent.py create mode 100644 flytekit/plugins/flytekit-snowflake/flytekitplugins/snowflake/task.py create mode 100644 flytekit/plugins/flytekit-snowflake/setup.py create mode 100644 flytekit/plugins/flytekit-snowflake/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-snowflake/tests/test_agent.py create mode 100644 flytekit/plugins/flytekit-snowflake/tests/test_snowflake.py create mode 100644 flytekit/plugins/flytekit-spark/Dockerfile create mode 100644 flytekit/plugins/flytekit-spark/README.md create mode 100644 flytekit/plugins/flytekit-spark/dev-requirements.in create mode 100644 flytekit/plugins/flytekit-spark/dev-requirements.txt create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/__init__.py create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/agent.py create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/models.py create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/pyspark_transformers.py create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/schema.py create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/sd_transformers.py create mode 100644 flytekit/plugins/flytekit-spark/flytekitplugins/spark/task.py create mode 100644 flytekit/plugins/flytekit-spark/scripts/flytekit_install_spark3.sh create mode 100644 flytekit/plugins/flytekit-spark/setup.py create mode 100644 flytekit/plugins/flytekit-spark/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-spark/tests/test_agent.py create mode 100644 flytekit/plugins/flytekit-spark/tests/test_pyspark_transformers.py create mode 100644 flytekit/plugins/flytekit-spark/tests/test_remote_register.py create mode 100644 flytekit/plugins/flytekit-spark/tests/test_spark_task.py create mode 100644 flytekit/plugins/flytekit-spark/tests/test_wf.py create mode 100644 flytekit/plugins/flytekit-sqlalchemy/Dockerfile create mode 100644 flytekit/plugins/flytekit-sqlalchemy/README.md create mode 100644 flytekit/plugins/flytekit-sqlalchemy/flytekitplugins/sqlalchemy/__init__.py create mode 100644 flytekit/plugins/flytekit-sqlalchemy/flytekitplugins/sqlalchemy/task.py create mode 100644 flytekit/plugins/flytekit-sqlalchemy/setup.py create mode 100644 flytekit/plugins/flytekit-sqlalchemy/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-sqlalchemy/tests/test_sql_tracker.py create mode 100644 flytekit/plugins/flytekit-sqlalchemy/tests/test_task.py create mode 100644 flytekit/plugins/flytekit-vaex/README.md create mode 100644 flytekit/plugins/flytekit-vaex/flytekitplugins/vaex/__init__.py create mode 100644 flytekit/plugins/flytekit-vaex/flytekitplugins/vaex/sd_transformers.py create mode 100644 flytekit/plugins/flytekit-vaex/setup.py create mode 100644 flytekit/plugins/flytekit-vaex/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-vaex/tests/test_vaex_plugin_sd.py create mode 100644 flytekit/plugins/flytekit-whylogs/README.md create mode 100644 flytekit/plugins/flytekit-whylogs/flytekitplugins/whylogs/__init__.py create mode 100644 flytekit/plugins/flytekit-whylogs/flytekitplugins/whylogs/renderer.py create mode 100644 flytekit/plugins/flytekit-whylogs/flytekitplugins/whylogs/schema.py create mode 100644 flytekit/plugins/flytekit-whylogs/requirements.in create mode 100644 flytekit/plugins/flytekit-whylogs/requirements.txt create mode 100644 flytekit/plugins/flytekit-whylogs/setup.py create mode 100644 flytekit/plugins/flytekit-whylogs/tests/__init__.py create mode 100644 flytekit/plugins/flytekit-whylogs/tests/test_renderer.py create mode 100644 flytekit/plugins/flytekit-whylogs/tests/test_schema.py create mode 100755 flytekit/plugins/run_all_plugins.sh create mode 100644 flytekit/plugins/setup.py create mode 100644 flytekit/pull_request_template.md create mode 100644 flytekit/pyproject.toml create mode 100644 flytekit/requirements.in create mode 100644 flytekit/setup.cfg create mode 100644 flytekit/setup.py create mode 100644 flytekit/tests/__init__.py create mode 100644 flytekit/tests/flytekit/__init__.py create mode 100644 flytekit/tests/flytekit/common/__init__.py create mode 100644 flytekit/tests/flytekit/common/configs/deprecated_local.config create mode 100644 flytekit/tests/flytekit/common/configs/local.config create mode 100644 flytekit/tests/flytekit/common/parameterizers.py create mode 100644 flytekit/tests/flytekit/conftest.py create mode 100644 flytekit/tests/flytekit/integration/__init__.py create mode 100644 flytekit/tests/flytekit/integration/experimental/__init__.py create mode 100644 flytekit/tests/flytekit/integration/experimental/eager_workflows.py create mode 100644 flytekit/tests/flytekit/integration/experimental/test_eager_workflows.py create mode 100644 flytekit/tests/flytekit/integration/remote/__init__.py create mode 100644 flytekit/tests/flytekit/integration/remote/mock_flyte_repo/workflows/requirements.txt create mode 100644 flytekit/tests/flytekit/integration/remote/test_remote.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/basic_workflow.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/child_workflow.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/dict_str_wf.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/hello_world.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/joblib.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/list_float_wf.py create mode 100644 flytekit/tests/flytekit/integration/remote/workflows/basic/subworkflows.py create mode 100644 flytekit/tests/flytekit/unit/__init__.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/__init__.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/conftest.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/default_arguments/collection_wf.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/default_arguments/dataclass_wf.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/default_arguments/map_wf.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/imageSpec.yaml create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/image_spec_wf.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/imperative_wf.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_backfill.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_build.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_init.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_launchplan.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_main.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_nested_wf/a/b/c/__init__.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_nested_wf/a/b/c/d/__init__.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_nested_wf/a/b/c/d/wf.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_package.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_plugin.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_register.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_run.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/test_serve.py create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/testdata/df.parquet create mode 100644 flytekit/tests/flytekit/unit/cli/pyflyte/workflow.py create mode 100644 flytekit/tests/flytekit/unit/cli/test_cli_helpers.py create mode 100644 flytekit/tests/flytekit/unit/cli/test_flyte_cli.py create mode 100644 flytekit/tests/flytekit/unit/clients/__init__.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth/__init__.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth/test_auth_client.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth/test_authenticator.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth/test_default_html.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth/test_keyring_store.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth/test_token_client.py create mode 100644 flytekit/tests/flytekit/unit/clients/auth_deprecation.config create mode 100644 flytekit/tests/flytekit/unit/clients/auth_deprecation2.config create mode 100644 flytekit/tests/flytekit/unit/clients/auth_deprecation3.config create mode 100644 flytekit/tests/flytekit/unit/clients/test_auth_helper.py create mode 100644 flytekit/tests/flytekit/unit/clients/test_friendly.py create mode 100644 flytekit/tests/flytekit/unit/clients/test_raw.py create mode 100644 flytekit/tests/flytekit/unit/common_tests/resources/protos/CompiledWorkflowClosure.pb create mode 100644 flytekit/tests/flytekit/unit/common_tests/resources/protos/OneTaskWFForPromote.pb create mode 100644 flytekit/tests/flytekit/unit/common_tests/test_workflow_promote.py create mode 100644 flytekit/tests/flytekit/unit/configuration/__init__.py create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/bad.config create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/creds_secret_env_var.yaml create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/creds_secret_location.yaml create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/fake_secret create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/good.config create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/images.config create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/no_images.yaml create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/nossl.yaml create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/sample.yaml create mode 100644 flytekit/tests/flytekit/unit/configuration/configs/sample.yml create mode 100644 flytekit/tests/flytekit/unit/configuration/test_file.py create mode 100644 flytekit/tests/flytekit/unit/configuration/test_image_config.py create mode 100644 flytekit/tests/flytekit/unit/configuration/test_internal.py create mode 100644 flytekit/tests/flytekit/unit/configuration/test_yaml_file.py create mode 100644 flytekit/tests/flytekit/unit/conftest.py create mode 100644 flytekit/tests/flytekit/unit/core/__init__.py create mode 100644 flytekit/tests/flytekit/unit/core/configs/images.config create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/__init__.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/decorator_source.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/decorator_usage.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/nested_function.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/nested_wrapped_function.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/simple_decorator.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/stacked_decorators.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/test_decorator_location.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/test_decorators.py create mode 100644 flytekit/tests/flytekit/unit/core/flyte_functools/unwrapped_decorator.py create mode 100644 flytekit/tests/flytekit/unit/core/image_spec/__init__.py create mode 100644 flytekit/tests/flytekit/unit/core/image_spec/registry_config.json create mode 100644 flytekit/tests/flytekit/unit/core/image_spec/requirements.txt create mode 100644 flytekit/tests/flytekit/unit/core/image_spec/test_image_spec.py create mode 100644 flytekit/tests/flytekit/unit/core/tasks.py create mode 100644 flytekit/tests/flytekit/unit/core/test_are_types_castable.py create mode 100644 flytekit/tests/flytekit/unit/core/test_array_node_map_task.py create mode 100644 flytekit/tests/flytekit/unit/core/test_artifacts.py create mode 100644 flytekit/tests/flytekit/unit/core/test_checkpoint.py create mode 100644 flytekit/tests/flytekit/unit/core/test_checkpointer.py create mode 100644 flytekit/tests/flytekit/unit/core/test_complex_nesting.py create mode 100644 flytekit/tests/flytekit/unit/core/test_composition.py create mode 100644 flytekit/tests/flytekit/unit/core/test_conditions.py create mode 100644 flytekit/tests/flytekit/unit/core/test_container_task.py create mode 100644 flytekit/tests/flytekit/unit/core/test_context_manager.py create mode 100644 flytekit/tests/flytekit/unit/core/test_data.py create mode 100644 flytekit/tests/flytekit/unit/core/test_data_persistence.py create mode 100644 flytekit/tests/flytekit/unit/core/test_dataclass.py create mode 100644 flytekit/tests/flytekit/unit/core/test_docstring.py create mode 100644 flytekit/tests/flytekit/unit/core/test_dynamic.py create mode 100644 flytekit/tests/flytekit/unit/core/test_dynamic_conditional.py create mode 100644 flytekit/tests/flytekit/unit/core/test_flyte_directory.py create mode 100644 flytekit/tests/flytekit/unit/core/test_flyte_file.py create mode 100644 flytekit/tests/flytekit/unit/core/test_flyte_typing.py create mode 100644 flytekit/tests/flytekit/unit/core/test_gate.py create mode 100644 flytekit/tests/flytekit/unit/core/test_imperative.py create mode 100644 flytekit/tests/flytekit/unit/core/test_imperative_with_patching.py create mode 100644 flytekit/tests/flytekit/unit/core/test_interface.py create mode 100644 flytekit/tests/flytekit/unit/core/test_launch_plan.py create mode 100644 flytekit/tests/flytekit/unit/core/test_literals_resolver.py create mode 100644 flytekit/tests/flytekit/unit/core/test_local_cache.py create mode 100644 flytekit/tests/flytekit/unit/core/test_map_task.py create mode 100644 flytekit/tests/flytekit/unit/core/test_node_creation.py create mode 100644 flytekit/tests/flytekit/unit/core/test_notifications.py create mode 100644 flytekit/tests/flytekit/unit/core/test_numpy.py create mode 100644 flytekit/tests/flytekit/unit/core/test_partials.py create mode 100644 flytekit/tests/flytekit/unit/core/test_promise.py create mode 100644 flytekit/tests/flytekit/unit/core/test_protobuf.py create mode 100644 flytekit/tests/flytekit/unit/core/test_python_auto_container.py create mode 100644 flytekit/tests/flytekit/unit/core/test_python_function_task.py create mode 100644 flytekit/tests/flytekit/unit/core/test_realworld_examples.py create mode 100644 flytekit/tests/flytekit/unit/core/test_references.py create mode 100644 flytekit/tests/flytekit/unit/core/test_resolver.py create mode 100644 flytekit/tests/flytekit/unit/core/test_resources.py create mode 100644 flytekit/tests/flytekit/unit/core/test_schedule.py create mode 100644 flytekit/tests/flytekit/unit/core/test_serialization.py create mode 100644 flytekit/tests/flytekit/unit/core/test_shim_task.py create mode 100644 flytekit/tests/flytekit/unit/core/test_signal.py create mode 100644 flytekit/tests/flytekit/unit/core/test_type_conversion_errors.py create mode 100644 flytekit/tests/flytekit/unit/core/test_type_delayed.py create mode 100644 flytekit/tests/flytekit/unit/core/test_type_engine.py create mode 100644 flytekit/tests/flytekit/unit/core/test_type_hints.py create mode 100644 flytekit/tests/flytekit/unit/core/test_typing_annotation.py create mode 100644 flytekit/tests/flytekit/unit/core/test_utils.py create mode 100644 flytekit/tests/flytekit/unit/core/test_workflows.py create mode 100644 flytekit/tests/flytekit/unit/core/test_wrapping.py create mode 100644 flytekit/tests/flytekit/unit/core/tracker/__init__.py create mode 100644 flytekit/tests/flytekit/unit/core/tracker/a.py create mode 100644 flytekit/tests/flytekit/unit/core/tracker/b.py create mode 100644 flytekit/tests/flytekit/unit/core/tracker/c.py create mode 100644 flytekit/tests/flytekit/unit/core/tracker/d.py create mode 100644 flytekit/tests/flytekit/unit/core/tracker/test_tracking.py create mode 100644 flytekit/tests/flytekit/unit/deck/test_deck.py create mode 100644 flytekit/tests/flytekit/unit/deck/test_renderer.py create mode 100644 flytekit/tests/flytekit/unit/exceptions/__init__.py create mode 100644 flytekit/tests/flytekit/unit/exceptions/test_base.py create mode 100644 flytekit/tests/flytekit/unit/exceptions/test_scopes.py create mode 100644 flytekit/tests/flytekit/unit/exceptions/test_system.py create mode 100644 flytekit/tests/flytekit/unit/exceptions/test_user.py create mode 100644 flytekit/tests/flytekit/unit/experimental/__init__.py create mode 100644 flytekit/tests/flytekit/unit/experimental/test_eager_workflows.py create mode 100644 flytekit/tests/flytekit/unit/extend/test_agent.py create mode 100644 flytekit/tests/flytekit/unit/extras/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/pytorch/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/pytorch/test_checkpoint.py create mode 100644 flytekit/tests/flytekit/unit/extras/pytorch/test_native.py create mode 100644 flytekit/tests/flytekit/unit/extras/pytorch/test_transformations.py create mode 100644 flytekit/tests/flytekit/unit/extras/sklearn/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/sklearn/test_native.py create mode 100644 flytekit/tests/flytekit/unit/extras/sklearn/test_transformations.py create mode 100644 flytekit/tests/flytekit/unit/extras/sqlite3/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/sqlite3/chinook.zip create mode 100644 flytekit/tests/flytekit/unit/extras/sqlite3/test_sql_tracker.py create mode 100644 flytekit/tests/flytekit/unit/extras/sqlite3/test_task.py create mode 100644 flytekit/tests/flytekit/unit/extras/tasks/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/tasks/test_shell.py create mode 100755 flytekit/tests/flytekit/unit/extras/tasks/testdata/long-running.sh create mode 100644 flytekit/tests/flytekit/unit/extras/tasks/testdata/script.exe create mode 100644 flytekit/tests/flytekit/unit/extras/tasks/testdata/script.sh create mode 100644 flytekit/tests/flytekit/unit/extras/tasks/testdata/script_args_env.sh create mode 100644 flytekit/tests/flytekit/unit/extras/tasks/testdata/test.csv create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/model/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/model/test_model.py create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/model/test_transformations.py create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/record/__init__.py create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/record/test_record.py create mode 100644 flytekit/tests/flytekit/unit/extras/tensorflow/record/test_transformations.py create mode 100644 flytekit/tests/flytekit/unit/extras/test_accelerators.py create mode 100644 flytekit/tests/flytekit/unit/interaction/__init__.py create mode 100644 flytekit/tests/flytekit/unit/interaction/test_click_types.py create mode 100644 flytekit/tests/flytekit/unit/interaction/test_string_literals.py create mode 100644 flytekit/tests/flytekit/unit/interfaces/__init__.py create mode 100644 flytekit/tests/flytekit/unit/interfaces/test_random.py create mode 100644 flytekit/tests/flytekit/unit/lazy_module/__init__.py create mode 100644 flytekit/tests/flytekit/unit/lazy_module/test_lazy_module.py create mode 100644 flytekit/tests/flytekit/unit/models/__init__.py create mode 100644 flytekit/tests/flytekit/unit/models/admin/__init__.py create mode 100644 flytekit/tests/flytekit/unit/models/admin/test_common.py create mode 100644 flytekit/tests/flytekit/unit/models/admin/test_node_executions.py create mode 100644 flytekit/tests/flytekit/unit/models/core/__init__.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_catalog.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_errors.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_execution.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_identifier.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_security.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_types.py create mode 100644 flytekit/tests/flytekit/unit/models/core/test_workflow.py create mode 100644 flytekit/tests/flytekit/unit/models/test_common.py create mode 100644 flytekit/tests/flytekit/unit/models/test_documentation.py create mode 100644 flytekit/tests/flytekit/unit/models/test_dynamic_job.py create mode 100644 flytekit/tests/flytekit/unit/models/test_execution.py create mode 100644 flytekit/tests/flytekit/unit/models/test_filters.py create mode 100644 flytekit/tests/flytekit/unit/models/test_interface.py create mode 100644 flytekit/tests/flytekit/unit/models/test_launch_plan.py create mode 100644 flytekit/tests/flytekit/unit/models/test_literals.py create mode 100644 flytekit/tests/flytekit/unit/models/test_matchable_resource.py create mode 100644 flytekit/tests/flytekit/unit/models/test_named_entity.py create mode 100644 flytekit/tests/flytekit/unit/models/test_project.py create mode 100644 flytekit/tests/flytekit/unit/models/test_qubole.py create mode 100644 flytekit/tests/flytekit/unit/models/test_schedule.py create mode 100644 flytekit/tests/flytekit/unit/models/test_tasks.py create mode 100644 flytekit/tests/flytekit/unit/models/test_types.py create mode 100644 flytekit/tests/flytekit/unit/models/test_workflow_closure.py create mode 100644 flytekit/tests/flytekit/unit/remote/__init__.py create mode 100644 flytekit/tests/flytekit/unit/remote/resources.py create mode 100644 flytekit/tests/flytekit/unit/remote/responses/CompiledWorkflowClosure.pb create mode 100644 flytekit/tests/flytekit/unit/remote/responses/admin.launch_plan_pb2.LaunchPlan_core.control_flow.subworkflows.leaf_subwf.pb create mode 100644 flytekit/tests/flytekit/unit/remote/responses/admin.task_pb2.Task.pb create mode 100644 flytekit/tests/flytekit/unit/remote/responses/admin.workflow_pb2.Workflow_core.control_flow.subworkflows.root_level_wf.pb create mode 100644 flytekit/tests/flytekit/unit/remote/test_backfill.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_calling.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_fs_remote.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_lazy_entity.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_mocked.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_remote.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_sandbox.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_with_responses.py create mode 100644 flytekit/tests/flytekit/unit/remote/test_wrapper_classes.py create mode 100644 flytekit/tests/flytekit/unit/remote/wf_with_remote.py create mode 100644 flytekit/tests/flytekit/unit/sensor/__init__.py create mode 100644 flytekit/tests/flytekit/unit/sensor/test_file_sensor.py create mode 100644 flytekit/tests/flytekit/unit/sensor/test_sensor_engine.py create mode 100644 flytekit/tests/flytekit/unit/test_translator.py create mode 100644 flytekit/tests/flytekit/unit/tools/__init__.py create mode 100644 flytekit/tests/flytekit/unit/tools/test_fast_registration.py create mode 100644 flytekit/tests/flytekit/unit/tools/test_ignore.py create mode 100644 flytekit/tests/flytekit/unit/tools/test_module_loader.py create mode 100644 flytekit/tests/flytekit/unit/tools/test_repo.py create mode 100644 flytekit/tests/flytekit/unit/tools/test_script_mode.py create mode 100644 flytekit/tests/flytekit/unit/tools/test_subprocess.py create mode 100644 flytekit/tests/flytekit/unit/types/directory/__init__.py create mode 100644 flytekit/tests/flytekit/unit/types/directory/test_types.py create mode 100644 flytekit/tests/flytekit/unit/types/error/__init__.py create mode 100644 flytekit/tests/flytekit/unit/types/error/test_error.py create mode 100644 flytekit/tests/flytekit/unit/types/file/__init__.py create mode 100644 flytekit/tests/flytekit/unit/types/file/test_image.py create mode 100644 flytekit/tests/flytekit/unit/types/iterator/test_iterator.py create mode 100644 flytekit/tests/flytekit/unit/types/numpy/test_ndarray.py create mode 100644 flytekit/tests/flytekit/unit/types/pickle/test_flyte_pickle.py create mode 100644 flytekit/tests/flytekit/unit/types/schema/test_schema_types.py create mode 100644 flytekit/tests/flytekit/unit/types/structured_dataset/test_arrow_data.py create mode 100644 flytekit/tests/flytekit/unit/types/structured_dataset/test_bigquery.py create mode 100644 flytekit/tests/flytekit/unit/types/structured_dataset/test_structured_dataset.py create mode 100644 flytekit/tests/flytekit/unit/types/structured_dataset/test_structured_dataset_handlers.py create mode 100644 flytekit/tests/flytekit/unit/types/structured_dataset/test_structured_dataset_workflow.py diff --git a/flytekit/.github/config.yml b/flytekit/.github/config.yml new file mode 100644 index 0000000000..04e92f6e6f --- /dev/null +++ b/flytekit/.github/config.yml @@ -0,0 +1,16 @@ +# Comment to be posted on PRs from first-time contributors in your repository +newPRWelcomeComment: | + Thank you for opening this pull request! 🙌 + + These tips will help get your PR across the finish line: + + - Most of the repos have a PR template; if not, fill it out to the best of your knowledge. + - Sign off your commits (Reference: [DCO Guide](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md)). + +# Comment to be posted to on pull requests merged by a first time user +firstPRMergeComment: > + Congrats on merging your first pull request! 🎉 + +# Comment to be posted on first-time issues +newIssueWelcomeComment: > + Thank you for opening your first issue here! 🛠 diff --git a/flytekit/.github/workflows/build_image.yml b/flytekit/.github/workflows/build_image.yml new file mode 100644 index 0000000000..c2d0e97bcb --- /dev/null +++ b/flytekit/.github/workflows/build_image.yml @@ -0,0 +1,86 @@ +name: Publish Python Package + +on: + workflow_dispatch: + +jobs: + build-and-push-docker-images-manual: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + 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 + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: "${{ secrets.FLYTE_BOT_USERNAME }}" + password: "${{ secrets.FLYTE_BOT_PAT }}" + - name: Prepare Flytekit Image Names + id: flytekit-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flytekit + tags: | + py${{ matrix.python-version }}-${{ github.sha }} + - name: Build & Push Flytekit Python${{ matrix.python-version }} Docker Image to Github Registry + uses: docker/build-push-action@v2 + with: + context: . + platforms: linux/arm64, linux/amd64 + push: true + tags: ${{ steps.flytekit-names.outputs.tags }} + build-args: | + VERSION=${{ github.sha }} + DOCKER_IMAGE=ghcr.io/${{ github.repository_owner }}/flytekit:py${{ matrix.python-version }}-${{ github.sha }} + PYTHON_VERSION=${{ matrix.python-version }} + file: Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max + + build-and-push-flyteagent-images-manual: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + 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 + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: "${{ secrets.FLYTE_BOT_USERNAME }}" + password: "${{ secrets.FLYTE_BOT_PAT }}" + - name: Prepare Flyte Agent Image Names + id: flyteagent-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flyteagent + tags: | + ${{ github.sha }} + - name: Push External Plugin Service Image to GitHub Registry + uses: docker/build-push-action@v2 + with: + context: "." + platforms: linux/arm64, linux/amd64 + push: true + tags: ${{ steps.flyteagent-names.outputs.tags }} + build-args: | + VERSION=${{ github.sha }} + file: ./Dockerfile.agent + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/flytekit/.github/workflows/monodocs_build.yml b/flytekit/.github/workflows/monodocs_build.yml new file mode 100644 index 0000000000..7b30ef957d --- /dev/null +++ b/flytekit/.github/workflows/monodocs_build.yml @@ -0,0 +1,54 @@ +name: Monodocs Build + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + docs: + name: Monodocs Build + runs-on: ubuntu-latest + steps: + - name: Fetch flytekit code + uses: actions/checkout@v4 + with: + path: "${{ github.workspace }}/flytekit" + - name: Fetch flyte code + uses: actions/checkout@v4 + with: + repository: flyteorg/flyte + path: "${{ github.workspace }}/flyte" + - uses: conda-incubator/setup-miniconda@v3 + with: + auto-update-conda: true + python-version: 3.9 + - shell: bash -el {0} + working-directory: ${{ github.workspace }}/flyte + run: | + conda install -c conda-forge conda-lock + conda-lock install -n monodocs-env monodocs-environment.lock.yaml + - shell: bash -el {0} + working-directory: ${{ github.workspace }}/flyte + run: | + conda activate monodocs-env + pip install -e ./flyteidl + conda info + conda list + conda config --show-sources + conda config --show + printenv | sort + - name: Build the documentation + working-directory: ${{ github.workspace }}/flyte + shell: bash -el {0} + env: + FLYTEKIT_LOCAL_PATH: ${{ github.workspace }}/flytekit + run: | + conda activate monodocs-env + make -C docs clean html SPHINXOPTS="-W -vvv" diff --git a/flytekit/.github/workflows/pythonbuild.yml b/flytekit/.github/workflows/pythonbuild.yml new file mode 100644 index 0000000000..8ec1c1c0a7 --- /dev/null +++ b/flytekit/.github/workflows/pythonbuild.yml @@ -0,0 +1,313 @@ +name: Build + +on: + push: + branches: + - master + pull_request: + +env: + FLYTE_SDK_LOGGING_LEVEL: 10 # debug + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.8", "3.11", "3.12"] + steps: + - uses: insightsengineering/disk-space-reclaimer@v1 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip + uses: actions/cache@v3 + with: + # This path is specific to Ubuntu + path: ~/.cache/pip + # Look to see if there is a cache hit for the corresponding requirements files + key: ${{ format('{0}-pip-{1}', runner.os, hashFiles('dev-requirements.in', 'requirements.in')) }} + - name: Install dependencies + run: | + make setup + pip uninstall -y pandas + pip freeze + - name: Run extras unit tests with coverage + # Skip this step if running on python 3.12 due to https://github.com/tensorflow/tensorflow/issues/62003 + # and https://github.com/pytorch/pytorch/issues/110436 + if: ${{ matrix.python-version != '3.12' }} + run: | + make unit_test_extras_codecov + - name: Test with coverage + run: | + make unit_test_codecov + - name: Codecov + uses: codecov/codecov-action@v3.1.4 + with: + fail_ci_if_error: false + files: coverage.xml + + build-with-pandas: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + python-version: [ "3.11" ] + pandas: [ "pandas<2.0.0", "pandas>=2.0.0" ] + steps: + - uses: insightsengineering/disk-space-reclaimer@v1 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip + uses: actions/cache@v3 + with: + # This path is specific to Ubuntu + path: ~/.cache/pip + # Look to see if there is a cache hit for the corresponding requirements files + key: ${{ format('{0}-pip-{1}', runner.os, hashFiles('dev-requirements.in', 'requirements.in')) }} + - name: Install dependencies + run: | + make setup + pip install --force-reinstall "${{ matrix.pandas }}" + pip freeze + - name: Test with coverage + run: | + make unit_test_codecov + - name: Codecov + uses: codecov/codecov-action@v3.1.4 + with: + fail_ci_if_error: false + files: coverage.xml + + test-serialization: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.8", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip + uses: actions/cache@v3 + with: + # This path is specific to Ubuntu + path: ~/.cache/pip + # Look to see if there is a cache hit for the corresponding requirements files + key: ${{ format('{0}-pip-{1}', runner.os, hashFiles('dev-requirements.in', 'requirements.in')) }} + - name: Install dependencies + run: make setup && pip freeze + - name: Test with coverage + run: | + make test_serialization_codecov + - name: Codecov + uses: codecov/codecov-action@v3.1.4 + with: + fail_ci_if_error: false + files: coverage.xml + + integration: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + # python 3.11 has intermittent issues with the docker build + push step + # https://github.com/flyteorg/flytekit/actions/runs/5800978835/job/15724237979?pr=1579 + python-version: ["3.8", "3.11", "3.12"] + steps: + - uses: insightsengineering/disk-space-reclaimer@v1 + # As described in https://github.com/pypa/setuptools_scm/issues/414, SCM needs git history + # and tags to work. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip + uses: actions/cache@v3 + with: + # This path is specific to Ubuntu + path: ~/.cache/pip + # Look to see if there is a cache hit for the corresponding requirements files + key: ${{ format('{0}-pip-{1}', runner.os, hashFiles('dev-requirements.in', 'requirements.in')) }} + - name: Install dependencies + run: make setup && pip freeze + - name: Install FlyteCTL + uses: unionai-oss/flytectl-setup-action@master + - name: Setup Flyte Sandbox + run: | + flytectl demo start + - name: Build and push to local registry + run: | + docker build --push . -f Dockerfile.dev -t localhost:30000/flytekit:dev --build-arg PYTHON_VERSION=${{ matrix.python-version }} + - name: Integration Test with coverage + env: + FLYTEKIT_IMAGE: localhost:30000/flytekit:dev + FLYTEKIT_CI: 1 + PYTEST_OPTS: -n2 + run: make integration_test_codecov + - name: Codecov + uses: codecov/codecov-action@v3.1.0 + with: + fail_ci_if_error: false + + build-plugins: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.11"] + plugin-names: + # Please maintain an alphabetical order in the following list + - flytekit-airflow + - flytekit-async-fsspec + - flytekit-aws-athena + - flytekit-aws-batch + # TODO: uncomment this when the sagemaker agent is implemented: https://github.com/flyteorg/flyte/issues/4079 + # - flytekit-aws-sagemaker + - flytekit-bigquery + - flytekit-dask + - flytekit-data-fsspec + - flytekit-dbt + - flytekit-deck-standard + - flytekit-dolt + - flytekit-duckdb + - flytekit-envd + - flytekit-greatexpectations + - flytekit-hive + - flytekit-huggingface + - flytekit-k8s-pod + - flytekit-kf-mpi + - flytekit-kf-pytorch + - flytekit-kf-tensorflow + - flytekit-mlflow + - flytekit-modin + - flytekit-onnx-pytorch + - flytekit-onnx-scikitlearn + # onnx-tensorflow needs a version of tensorflow that does not work with protobuf>4. + # The issue is being tracked on the tensorflow side in https://github.com/tensorflow/tensorflow/issues/53234#issuecomment-1330111693 + # flytekit-onnx-tensorflow + - flytekit-pandera + - flytekit-papermill + - flytekit-polars + - flytekit-ray + - flytekit-snowflake + - flytekit-spark + - flytekit-sqlalchemy + - flytekit-vaex + - flytekit-flyteinteractive + - flytekit-whylogs + exclude: + # flytekit-modin depends on ray which does not have a 3.11 wheel yet. + # Issue tracked in https://github.com/ray-project/ray/issues/27881 + - python-version: 3.11 + plugin-names: "flytekit-modin" + - python-version: 3.11 + plugin-names: "flytekit-ray" + # Great-expectations does not support python 3.11 due to sqlachemy>=2.0.0 + # not being supported yet: + # https://github.com/great-expectations/great_expectations/issues/7020 + - python-version: 3.11 + plugin-names: "flytekit-greatexpectations" + # onnxruntime does not support python 3.10 yet + # https://github.com/microsoft/onnxruntime/issues/9782 + - python-version: 3.11 + plugin-names: "flytekit-onnx-pytorch" + - python-version: 3.11 + plugin-names: "flytekit-onnx-scikitlearn" + - python-version: 3.11 + plugin-names: "flytekit-onnx-tensorflow" + # numba, a dependency of mlflow, doesn't support python 3.11 + # https://github.com/numba/numba/issues/8304 + - python-version: 3.11 + plugin-names: "flytekit-mlflow" + # vaex currently doesn't support python 3.11 + - python-version: 3.11 + plugin-names: "flytekit-vaex" + # whylogs does support python 3.11 dataclass restrictions + # See: https://github.com/flyteorg/flytekit/actions/runs/4493746408/jobs/7905368664 + - python-version: 3.11 + plugin-names: "flytekit-whylogs" + steps: + - uses: insightsengineering/disk-space-reclaimer@v1 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Cache pip + uses: actions/cache@v3 + with: + # This path is specific to Ubuntu + path: ~/.cache/pip + # Look to see if there is a cache hit for the corresponding requirements files + key: ${{ format('{0}-pip-{1}', runner.os, hashFiles('dev-requirements.txt', format('plugins/{0}/requirements.txt', matrix.plugin-names ))) }} + - name: Install dependencies + run: | + make setup + cd plugins/${{ matrix.plugin-names }} + pip install --pre . + if [ -f dev-requirements.txt ]; then pip install -r dev-requirements.txt; fi + pip install --pre -U $GITHUB_WORKSPACE + pip freeze + - name: Test with coverage + run: | + cd plugins/${{ matrix.plugin-names }} + # onnx plugins does not support protobuf>4 yet (in fact it is tensorflow that + # does not support that yet). More details in https://github.com/onnx/onnx/issues/4239. + if [[ ${{ matrix.plugin-names }} == *"onnx"* || ${{ matrix.plugin-names }} == "flytekit-whylogs" || ${{ matrix.plugin-names }} == "flytekit-mlflow" ]]; then + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python coverage run -m pytest tests --cov=./ --cov-report=xml --cov-append + else + coverage run -m pytest tests --cov=./ --cov-report=xml --cov-append + fi + - name: Codecov + uses: codecov/codecov-action@v3.1.0 + with: + fail_ci_if_error: false + lint: + runs-on: ubuntu-latest + steps: + - name: Fetch the code + uses: actions/checkout@v4 + - name: Set up Python 3.8 + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/dev-requirements.in') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + make setup + pip freeze + - name: Lint + run: | + make lint + - name: ShellCheck + uses: ludeeus/action-shellcheck@master + with: + ignore_paths: boilerplate diff --git a/flytekit/.github/workflows/pythonpublish.yml b/flytekit/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000000..d82fcfb4d9 --- /dev/null +++ b/flytekit/.github/workflows/pythonpublish.yml @@ -0,0 +1,275 @@ +name: Publish Python Package + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: "0" + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine setuptools wheel + - name: Put version in environment + id: bump + run: | + # from refs/tags/v1.2.3 get 1.2.3 + VERSION=$(echo $GITHUB_REF | sed 's#.*/v##') + echo "::set-output name=version::$VERSION" + shell: bash + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build + twine upload dist/* + - name: Autobump plugin version + run: | + # from refs/tags/v1.2.3 get 1.2.3 + VERSION=$(echo $GITHUB_REF | sed 's#.*/v##') + VERSION=$VERSION make -C plugins update_all_versions + shell: bash + - name: Build all Plugins and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + make -C plugins build_all_plugins + make -C plugins publish_all_plugins + - 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 + sleep 300 + LINK="https://pypi.org/project/flytekitplugins-pod/${VERSION}/" + for i in {1..60}; do + result=$(curl -L -I -s -f ${LINK}) + if [ $? -eq 0 ]; then + echo "Found pypi for $LINK" + 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 }} + + build-and-push-docker-images: + runs-on: ubuntu-latest + needs: deploy + strategy: + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + steps: + - uses: actions/checkout@v4 + 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 Flytekit Image Names + id: flytekit-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flytekit + tags: | + py${{ matrix.python-version }}-latest + py${{ matrix.python-version }}-${{ github.sha }} + py${{ matrix.python-version }}-${{ needs.deploy.outputs.version }} + - name: Build & Push Flytekit Python${{ matrix.python-version }} Docker Image to Github Registry + uses: docker/build-push-action@v2 + with: + context: . + platforms: linux/arm64, linux/amd64 + push: ${{ github.event_name == 'release' }} + tags: ${{ steps.flytekit-names.outputs.tags }} + build-args: | + VERSION=${{ needs.deploy.outputs.version }} + DOCKER_IMAGE=ghcr.io/${{ github.repository_owner }}/flytekit:py${{ matrix.python-version }}-${{ needs.deploy.outputs.version }} + PYTHON_VERSION=${{ matrix.python-version }} + file: Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Prepare SQLAlchemy Image Names + id: sqlalchemy-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flytekit + tags: | + py${{ matrix.python-version }}-sqlalchemy-latest + py${{ matrix.python-version }}-sqlalchemy-${{ github.sha }} + py${{ matrix.python-version }}-sqlalchemy-${{ needs.deploy.outputs.version }} + - name: Push SQLAlchemy Image to GitHub Registry + uses: docker/build-push-action@v2 + with: + context: "./plugins/flytekit-sqlalchemy/" + platforms: linux/arm64, linux/amd64 + push: ${{ github.event_name == 'release' }} + tags: ${{ steps.sqlalchemy-names.outputs.tags }} + build-args: | + VERSION=${{ needs.deploy.outputs.version }} + PYTHON_VERSION=${{ matrix.python-version }} + file: ./plugins/flytekit-sqlalchemy/Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max + + build-and-push-flyteagent-images: + runs-on: ubuntu-latest + needs: deploy + steps: + - uses: actions/checkout@v4 + 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 Flyte Agent Image Names + id: flyteagent-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flyteagent + 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.flyteagent-names.outputs.tags }} + build-args: | + VERSION=${{ needs.deploy.outputs.version }} + file: ./Dockerfile.agent + 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@v4 + 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 + + build-and-push-flyteinteractive-images: + runs-on: ubuntu-latest + needs: deploy + steps: + - uses: actions/checkout@v4 + 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 flyteinteractive Image Names + id: flyteinteractive-names + uses: docker/metadata-action@v3 + with: + images: | + ghcr.io/${{ github.repository_owner }}/flytekit + tags: | + flyteinteractive-latest + flyteinteractive-${{ github.sha }} + flyteinteractive-${{ needs.deploy.outputs.version }} + - name: Push Flyin Image to GitHub Registry + uses: docker/build-push-action@v2 + with: + context: "./plugins/flytekit-flyteinteractive/" + platforms: linux/arm64, linux/amd64 + push: ${{ github.event_name == 'release' }} + tags: ${{ steps.flyteinteractive-names.outputs.tags }} + build-args: | + VERSION=${{ needs.deploy.outputs.version }} + file: ./plugins/flytekit-flyteinteractive/Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/flytekit/.github/workflows/upgrade_automation.yml b/flytekit/.github/workflows/upgrade_automation.yml new file mode 100644 index 0000000000..e5f483eaef --- /dev/null +++ b/flytekit/.github/workflows/upgrade_automation.yml @@ -0,0 +1,12 @@ +name: Boilerplate Upgrade Automation +on: + workflow_dispatch: + +jobs: + trigger-upgrade: + name: Boilerplate Upgrade Automation + uses: flyteorg/flytetools/.github/workflows/flyte_automation.yml@master + with: + component: boilerplate + secrets: + FLYTE_BOT_PAT: ${{ secrets.FLYTE_BOT_PAT }} diff --git a/flytekit/.gitignore b/flytekit/.gitignore new file mode 100644 index 0000000000..751af02c9b --- /dev/null +++ b/flytekit/.gitignore @@ -0,0 +1,39 @@ +*.pyc +*.pyo +*.pyt +*.pytc +*.egg-info +.*.sw* +.DS_Store +venv/ +.venv/ +venv3/ +.cache/ +build/ +.idea/ +.coverage +.vscode +.Trash-0/ +.ipynb_checkpoints/ +.pytest_cache/ +dist +*.iml +.eggs +.demo +.bash_history +*-out.html +*-out.ipynb +.python-version +_build/ +docs/source/generated/ +docs/source/plugins/generated/ +.pytest_flyte +htmlcov +*.ipynb +*dat +docs/source/_tags/ +.hypothesis +.npm + +# Version file is auto-generated by setuptools_scm +flytekit/_version.py diff --git a/flytekit/.pre-commit-config.yaml b/flytekit/.pre-commit-config.yaml new file mode 100644 index 0000000000..1e91d21bd1 --- /dev/null +++ b/flytekit/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.6 + hooks: + # Run the linter. + - id: ruff + args: [--fix, --show-fixes, --show-source] + # Run the formatter. + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.2.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/shellcheck-py/shellcheck-py + 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 + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: + - tomli diff --git a/flytekit/.readthedocs.yml b/flytekit/.readthedocs.yml new file mode 100644 index 0000000000..a553c2f8e0 --- /dev/null +++ b/flytekit/.readthedocs.yml @@ -0,0 +1,22 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +build: + os: ubuntu-20.04 + tools: + python: "3.9" + apt_packages: + - graphviz + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Optionally set the version of Python and requirements required to build your docs +python: + install: + - requirements: doc-requirements.txt diff --git a/flytekit/CODEOWNERS b/flytekit/CODEOWNERS new file mode 100644 index 0000000000..a9aab29ffd --- /dev/null +++ b/flytekit/CODEOWNERS @@ -0,0 +1,3 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence. +* @wild-endeavor @kumare3 @eapolinario @pingsutw @cosmicBboy diff --git a/flytekit/CODE_OF_CONDUCT.md b/flytekit/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..d770146e09 --- /dev/null +++ b/flytekit/CODE_OF_CONDUCT.md @@ -0,0 +1,2 @@ +This project is governed by LF AI Foundation's [code of conduct](https://lfprojects.org/policies/code-of-conduct/). +All contributors and participants agree to abide by its terms. diff --git a/flytekit/Dockerfile b/flytekit/Dockerfile new file mode 100644 index 0000000000..63e4d301bc --- /dev/null +++ b/flytekit/Dockerfile @@ -0,0 +1,38 @@ +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION}-slim-bookworm + +MAINTAINER Flyte Team +LABEL org.opencontainers.image.source=https://github.com/flyteorg/flytekit + +WORKDIR /root +ENV PYTHONPATH /root +ENV FLYTE_SDK_RICH_TRACEBACKS 0 + +ARG VERSION +ARG DOCKER_IMAGE + +# Note: Pod tasks should be exposed in the default image +# Note: Some packages will create config files under /home by default, so we need to make sure it's writable +# Note: There are use cases that require reading and writing files under /tmp, so we need to change its permissions. + +# Run a series of commands to set up the environment: +# 1. Update and install dependencies. +# 2. Install Flytekit and its plugins. +# 3. Clean up the apt cache to reduce image size. Reference: https://gist.github.com/marvell/7c812736565928e602c4 +# 4. Create a non-root user 'flytekit' and set appropriate permissions for directories. +RUN apt-get update && apt-get install build-essential -y \ + && pip install --no-cache-dir -U flytekit==$VERSION \ + flytekitplugins-pod==$VERSION \ + flytekitplugins-deck-standard==$VERSION \ + scikit-learn \ + && apt-get clean autoclean \ + && apt-get autoremove --yes \ + && rm -rf /var/lib/{apt,dpkg,cache,log}/ \ + && useradd -u 1000 flytekit \ + && chown flytekit: /root \ + && chown flytekit: /home \ + && : + +USER flytekit + +ENV FLYTE_INTERNAL_IMAGE "$DOCKER_IMAGE" diff --git a/flytekit/Dockerfile.agent b/flytekit/Dockerfile.agent new file mode 100644 index 0000000000..fe4ce56290 --- /dev/null +++ b/flytekit/Dockerfile.agent @@ -0,0 +1,22 @@ +FROM python:3.9-slim-bookworm + +MAINTAINER Flyte Team +LABEL org.opencontainers.image.source=https://github.com/flyteorg/flytekit + +ARG VERSION + +RUN apt-get update && apt-get install build-essential -y + +RUN pip install prometheus-client +RUN pip install --no-cache-dir -U flytekit==$VERSION \ + flytekitplugins-bigquery==$VERSION \ + flytekitplugins-airflow==$VERSION \ + flytekitplugins-mmcloud==$VERSION \ + flytekitplugins-spark==$VERSION \ + flytekitplugins-snowflake==$VERSION \ + && apt-get clean autoclean \ + && apt-get autoremove --yes \ + && rm -rf /var/lib/{apt,dpkg,cache,log}/ \ + && : + +CMD pyflyte serve agent --port 8000 diff --git a/flytekit/Dockerfile.dev b/flytekit/Dockerfile.dev new file mode 100644 index 0000000000..2b85a5f7d2 --- /dev/null +++ b/flytekit/Dockerfile.dev @@ -0,0 +1,49 @@ +# 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-bookworm + +MAINTAINER Flyte Team +LABEL org.opencontainers.image.source=https://github.com/flyteorg/flytekit + +WORKDIR /root +ENV FLYTE_SDK_RICH_TRACEBACKS 0 + +ARG VERSION + +COPY . /flytekit + +# Note: Pod tasks should be exposed in the default image +# Note: Some packages will create config files under /home by default, so we need to make sure it's writable +# Note: There are use cases that require reading and writing files under /tmp, so we need to change its permissions. + +# Run a series of commands to set up the environment: +# 1. Update and install dependencies. +# 2. Install Flytekit and its plugins. +# 3. Clean up the apt cache to reduce image size. Reference: https://gist.github.com/marvell/7c812736565928e602c4 +# 4. Create a non-root user 'flytekit' and set appropriate permissions for directories. +RUN apt-get update && apt-get install build-essential vim libmagic1 git -y \ + && pip install --no-cache-dir -U --pre \ + flyteidl \ + -e /flytekit \ + -e /flytekit/plugins/flytekit-k8s-pod \ + -e /flytekit/plugins/flytekit-deck-standard \ + -e /flytekit/plugins/flytekit-flyteinteractive \ + scikit-learn \ + && apt-get clean autoclean \ + && apt-get autoremove --yes \ + && rm -rf /var/lib/{apt,dpkg,cache,log}/ \ + && useradd -u 1000 flytekit \ + && chown flytekit: /root \ + && chown flytekit: /home \ + && : + +ENV PYTHONPATH "/flytekit:/flytekit/plugins/flytekit-k8s-pod:/flytekit/plugins/flytekit-deck-standard:" + +# Switch to the 'flytekit' user for better security. +USER flytekit diff --git a/flytekit/LICENSE b/flytekit/LICENSE new file mode 100644 index 0000000000..bed437514f --- /dev/null +++ b/flytekit/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Lyft, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/flytekit/MANIFEST.in b/flytekit/MANIFEST.in new file mode 100644 index 0000000000..18dc8ed77c --- /dev/null +++ b/flytekit/MANIFEST.in @@ -0,0 +1,51 @@ +## Include files and folders + +# include folders +recursive-include flytekit * +recursive-include flytekit_scripts * + +# include specific files +include README.md +include flytekit/deck/html/template.html + +include CHANGELOG.md +include LICENSE + +include MANIFEST.in +include pyproject.toml +include setup.py +include setup.cfg + + +## Exclude files and folders + +# exclude folders +recursive-exclude tests * +recursive-exclude docs * +recursive-exclude boilerplate * +recursive-exclude .github * +recursive-exclude plugins * + +# exclude dist folder: +# - contains the generated *.tar.gz and .whl files. +recursive-exclude dist * + +# exclude requirements files +exclude requirements.* +exclude requirements-*.* +exclude doc-requirements.* +exclude dev-requirements.* + +# exclude config files +exclude .gitignore +exclude .readthedocs.yaml +exclude .pre-commit-config.yaml +exclude codecov.yml + +# exclude other repository management files +exclude Dockerfile.py* +exclude Makefile +exclude NOTICE +exclude pull_request_template.md +exclude CODEOWNERS +exclude CODE_OF_CONDUCT.md diff --git a/flytekit/Makefile b/flytekit/Makefile new file mode 100644 index 0000000000..ab93838004 --- /dev/null +++ b/flytekit/Makefile @@ -0,0 +1,103 @@ +export REPOSITORY=flytekit + +PIP_COMPILE = pip-compile --upgrade --verbose --resolver=backtracking +MOCK_FLYTE_REPO=tests/flytekit/integration/remote/mock_flyte_repo/workflows +PYTEST_OPTS ?= -n auto --dist=loadfile +PYTEST = pytest ${PYTEST_OPTS} + +.SILENT: help +.PHONY: help +help: + echo Available recipes: + cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' | awk 'BEGIN { FS = ":.*?## " } { cnt++; a[cnt] = $$1; b[cnt] = $$2; if (length($$1) > max) max = length($$1) } END { for (i = 1; i <= cnt; i++) printf " $(shell tput setaf 6)%-*s$(shell tput setaf 0) %s\n", max, a[i], b[i] }' + tput sgr0 + +.PHONY: install-piptools +install-piptools: + # pip 22.1 broke pip-tools: https://github.com/jazzband/pip-tools/issues/1617 + python -m pip install -U pip-tools setuptools wheel "pip>=22.0.3,!=22.1" + +.PHONY: update_boilerplate +update_boilerplate: + @curl https://raw.githubusercontent.com/flyteorg/boilerplate/master/boilerplate/update.sh -o boilerplate/update.sh + @boilerplate/update.sh + +.PHONY: setup +setup: install-piptools ## Install requirements + pip install --pre -r dev-requirements.in + + +.PHONY: fmt +fmt: + pre-commit run ruff --all-files || true + pre-commit run ruff-format --all-files || true + +.PHONY: lint +lint: ## Run linters + mypy flytekit/core + mypy flytekit/types +# allow-empty-bodies: Allow empty body in function. +# disable-error-code="annotation-unchecked": Remove the warning "By default the bodies of untyped functions are not checked". +# Mypy raises a warning because it cannot determine the type from the dataclass, despite we specified the type in the dataclass. + mypy --allow-empty-bodies --disable-error-code="annotation-unchecked" tests/flytekit/unit/core + pre-commit run --all-files + +.PHONY: spellcheck +spellcheck: ## Runs a spellchecker over all code and documentation + # Configuration is in pyproject.toml + codespell + +.PHONY: test +test: lint unit_test + +.PHONY: unit_test_codecov +unit_test_codecov: + $(MAKE) CODECOV_OPTS="--cov=./ --cov-report=xml --cov-append" unit_test + +.PHONY: unit_test_extras_codecov +unit_test_extras_codecov: + $(MAKE) CODECOV_OPTS="--cov=./ --cov-report=xml --cov-append" unit_test_extras + +.PHONY: unit_test +unit_test: + # Skip all extra tests and run them with the necessary env var set so that a working (albeit slower) + # library is used to serialize/deserialize protobufs is used. + $(PYTEST) -m "not sandbox_test" tests/flytekit/unit/ --ignore=tests/flytekit/unit/extras/ --ignore=tests/flytekit/unit/models ${CODECOV_OPTS} + +.PHONY: unit_test_extras +unit_test_extras: + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python $(PYTEST) tests/flytekit/unit/extras ${CODECOV_OPTS} + +.PHONY: test_serialization_codecov +test_serialization_codecov: + $(MAKE) CODECOV_OPTS="--cov=./ --cov-report=xml --cov-append" test_serialization + +.PHONY: test_serialization +test_serialization: + $(PYTEST) tests/flytekit/unit/models ${CODECOV_OPTS} + + +.PHONY: integration_test_codecov +integration_test_codecov: + $(MAKE) CODECOV_OPTS="--cov=./ --cov-report=xml --cov-append" integration_test + +.PHONY: integration_test +integration_test: + $(PYTEST) tests/flytekit/integration ${CODECOV_OPTS} + +doc-requirements.txt: export CUSTOM_COMPILE_COMMAND := make doc-requirements.txt +doc-requirements.txt: doc-requirements.in install-piptools + $(PIP_COMPILE) $< + +${MOCK_FLYTE_REPO}/requirements.txt: export CUSTOM_COMPILE_COMMAND := make ${MOCK_FLYTE_REPO}/requirements.txt +${MOCK_FLYTE_REPO}/requirements.txt: ${MOCK_FLYTE_REPO}/requirements.in install-piptools + $(PIP_COMPILE) $< + +.PHONY: requirements +requirements: doc-requirements.txt ${MOCK_FLYTE_REPO}/requirements.txt ## Compile requirements + +# TODO: Change this in the future to be all of flytekit +.PHONY: coverage +coverage: + coverage run -m pytest tests/flytekit/unit/core flytekit/types -m "not sandbox_test" + coverage report -m --include="flytekit/core/*,flytekit/types/*" diff --git a/flytekit/NOTICE b/flytekit/NOTICE new file mode 100644 index 0000000000..e117680f1f --- /dev/null +++ b/flytekit/NOTICE @@ -0,0 +1,4 @@ +flytekit +Copyright 2019-2020 Lyft Inc. + +This product includes software developed at Lyft Inc. diff --git a/flytekit/README.md b/flytekit/README.md new file mode 100644 index 0000000000..95ed844bad --- /dev/null +++ b/flytekit/README.md @@ -0,0 +1,78 @@ +

+ Flyte Logo +

+

+ Flytekit Python +

+

+ Flytekit Python is the Python SDK built on top of Flyte +

+

+ Plugins + · + Contribution Guide +

+ +[![PyPI version fury.io](https://badge.fury.io/py/flytekit.svg)](https://pypi.python.org/pypi/flytekit/) +[![PyPI download day](https://img.shields.io/pypi/dd/flytekit.svg)](https://pypi.python.org/pypi/flytekit/) +[![PyPI download month](https://img.shields.io/pypi/dm/flytekit.svg)](https://pypi.python.org/pypi/flytekit/) +[![PyPI total download](https://static.pepy.tech/badge/flytekit)](https://static.pepy.tech/badge/flytekit) +[![PyPI format](https://img.shields.io/pypi/format/flytekit.svg)](https://pypi.python.org/pypi/flytekit/) +[![PyPI implementation](https://img.shields.io/pypi/implementation/flytekit.svg)](https://pypi.python.org/pypi/flytekit/) +[![Codecov](https://img.shields.io/codecov/c/github/flyteorg/flytekit?style=plastic)](https://app.codecov.io/gh/flyteorg/flytekit) +[![PyPI pyversions](https://img.shields.io/pypi/pyversions/flytekit.svg)](https://pypi.python.org/pypi/flytekit/) +[![Docs](https://readthedocs.org/projects/flytekit/badge/?version=latest&style=plastic)](https://flytekit.rtfd.io) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Slack](https://img.shields.io/badge/slack-join_chat-white.svg?logo=slack&style=social)](https://slack.flyte.org) + +Flytekit Python is the Python Library for easily authoring, testing, deploying, and interacting with Flyte tasks, workflows, and launch plans. + +If you haven't explored Flyte yet, please refer to: + - [Flyte homepage](https://flyte.org) + - [Flyte core repository](https://github.com/flyteorg/flyte) + +## 🚀 Quick Start + +Flytekit is the core extensible library to author Flyte workflows and tasks and interact with Flyte backend services. + +### Installation + +```bash +pip install flytekit +``` + +### A Simple Example + +```python +from flytekit import task, workflow + +@task(cache=True, cache_version="1", retries=3) +def sum(x: int, y: int) -> int: + return x + y + +@task(cache=True, cache_version="1", retries=3) +def square(z: int) -> int: + return z*z + +@workflow +def my_workflow(x: int, y: int) -> int: + return sum(x=square(z=x), y=square(z=y)) +``` + +## 📦 Resources +- [Learn Flytekit by examples](https://flytecookbook.readthedocs.io/) +- [Flytekit API documentation](https://flytekit.readthedocs.io/) + + +## 📖 How to Contribute to Flytekit +You can find the detailed contribution guide [here](https://docs.flyte.org/projects/flytekit/en/latest/contributing.html). Plugins' contribution guide is included as well. + +## Code Structure +Please see the [contributor's guide](https://docs.flyte.org/projects/flytekit/en/latest/contributing.html) for a quick summary of how this code is structured. + +## 🐞 File an Issue +Refer to the [issues](https://docs.flyte.org/en/latest/community/contribute.html#file-an-issue) section in the contribution guide if you'd like to file an issue. + +## 🔌 Flytekit Plugins +Refer to [plugins/README.md](plugins/README.md) for a list of available plugins. +There may be plugins outside of this list, but the core maintainers maintain this list. diff --git a/flytekit/codecov.yml b/flytekit/codecov.yml new file mode 100644 index 0000000000..9c38fc7bc3 --- /dev/null +++ b/flytekit/codecov.yml @@ -0,0 +1,9 @@ +ignore: + - "flytekit/bin" + - "flytekit/clis/**/*" + - "test_*.py" + - "tests/**/*" + - "setup.py" + - "plugins/tests/**/*" + - "plugins/setup.py" + - "plugins/**/setup.py" diff --git a/flytekit/dev-requirements.in b/flytekit/dev-requirements.in new file mode 100644 index 0000000000..d9784f75d0 --- /dev/null +++ b/flytekit/dev-requirements.in @@ -0,0 +1,50 @@ +-e file:.#egg=flytekit + +coverage[toml] +hypothesis +joblib +mock +pytest +pytest-asyncio +pytest-cov +pytest-timeout +pytest-mock +pytest-xdist +mypy<1.7.0 +pre-commit +codespell +google-cloud-bigquery +google-cloud-bigquery-storage +IPython +keyrings.alt +setuptools_scm + +# Tensorflow is not available for python 3.12 yet: https://github.com/tensorflow/tensorflow/issues/62003 +tensorflow; python_version<'3.12' +# Newer versions of torch bring in nvidia dependencies that are not present in windows, so +# we put this constraint while we do not have per-environment requirements files +torch<=1.12.1; python_version<'3.11' +# pytorch 2 supports python 3.11 +# pytorch 2 does not support 3.12 yet: https://github.com/pytorch/pytorch/issues/110436 +torch; python_version<'3.12' + +# TODO: Currently, the python-magic library causes build errors on Windows due to its dependency on DLLs for libmagic. +# We have temporarily disabled this feature on Windows and are using python-magic for Mac OS and Linux instead. +# For more details, see the related GitHub issue. +# Once a solution is found, this should be updated to support Windows as well. +python-magic; (platform_system=='Darwin' or platform_system=='Linux') + +types-protobuf +types-croniter +types-mock +autoflake + +pillow +numpy +pandas +scikit-learn +types-requests +prometheus-client + +orjson +kubernetes>=12.0.1 diff --git a/flytekit/dev-requirements.txt b/flytekit/dev-requirements.txt new file mode 100644 index 0000000000..fa5840471b --- /dev/null +++ b/flytekit/dev-requirements.txt @@ -0,0 +1,562 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile dev-requirements.in +# +-e file:.#egg=flytekit + # via -r dev-requirements.in +absl-py==2.1.0 + # via + # tensorboard + # tensorflow-macos +adlfs==2023.9.0 + # via flytekit +aiobotocore==2.5.4 + # via s3fs +aiohttp==3.9.3 + # via + # adlfs + # aiobotocore + # gcsfs + # s3fs +aioitertools==0.11.0 + # via aiobotocore +aiosignal==1.3.1 + # via aiohttp +arrow==1.3.0 + # via cookiecutter +asttokens==2.4.1 + # via stack-data +astunparse==1.6.3 + # via tensorflow-macos +attrs==23.2.0 + # via + # aiohttp + # hypothesis +autoflake==2.2.1 + # via -r dev-requirements.in +azure-core==1.30.0 + # via + # adlfs + # azure-identity + # azure-storage-blob +azure-datalake-store==0.0.53 + # via adlfs +azure-identity==1.15.0 + # via adlfs +azure-storage-blob==12.19.0 + # via adlfs +binaryornot==0.4.4 + # via cookiecutter +botocore==1.31.17 + # via aiobotocore +cachetools==5.3.2 + # via google-auth +certifi==2024.2.2 + # via + # kubernetes + # requests +cffi==1.16.0 + # via + # azure-datalake-store + # cryptography +cfgv==3.4.0 + # via pre-commit +chardet==5.2.0 + # via binaryornot +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # cookiecutter + # flytekit + # rich-click +cloudpickle==3.0.0 + # via flytekit +codespell==2.2.6 + # via -r dev-requirements.in +cookiecutter==2.5.0 + # via flytekit +coverage[toml]==7.4.1 + # via + # -r dev-requirements.in + # pytest-cov +croniter==2.0.1 + # via flytekit +cryptography==42.0.2 + # via + # azure-identity + # azure-storage-blob + # msal + # pyjwt +dataclasses-json==0.5.9 + # via flytekit +decorator==5.1.1 + # via + # gcsfs + # ipython +diskcache==5.6.3 + # via flytekit +distlib==0.3.8 + # via virtualenv +docker==6.1.3 + # via flytekit +docstring-parser==0.15 + # via flytekit +execnet==2.0.2 + # via pytest-xdist +executing==2.0.1 + # via stack-data +filelock==3.13.1 + # via + # torch + # virtualenv +flatbuffers==23.5.26 + # via tensorflow-macos +flyteidl==1.10.6 + # via flytekit +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +fsspec==2023.9.2 + # via + # adlfs + # flytekit + # gcsfs + # s3fs + # torch +gast==0.5.4 + # via tensorflow-macos +gcsfs==2023.9.2 + # via flytekit +google-api-core[grpc]==2.16.2 + # via + # google-cloud-bigquery + # google-cloud-bigquery-storage + # google-cloud-core + # google-cloud-storage +google-auth==2.27.0 + # via + # gcsfs + # google-api-core + # google-auth-oauthlib + # google-cloud-core + # google-cloud-storage + # kubernetes + # tensorboard +google-auth-oauthlib==1.2.0 + # via + # gcsfs + # tensorboard +google-cloud-bigquery==3.17.1 + # via -r dev-requirements.in +google-cloud-bigquery-storage==2.24.0 + # via -r dev-requirements.in +google-cloud-core==2.4.1 + # via + # google-cloud-bigquery + # google-cloud-storage +google-cloud-storage==2.14.0 + # via gcsfs +google-crc32c==1.5.0 + # via + # google-cloud-storage + # google-resumable-media +google-pasta==0.2.0 + # via tensorflow-macos +google-resumable-media==2.7.0 + # via + # google-cloud-bigquery + # google-cloud-storage +googleapis-common-protos==1.62.0 + # via + # flyteidl + # flytekit + # google-api-core + # grpcio-status +grpcio==1.60.1 + # via + # flytekit + # google-api-core + # grpcio-status + # tensorboard + # tensorflow-macos +grpcio-status==1.60.1 + # via + # flytekit + # google-api-core +h5py==3.10.0 + # via tensorflow-macos +hypothesis==6.98.2 + # via -r dev-requirements.in +identify==2.5.33 + # via pre-commit +idna==3.6 + # via + # requests + # yarl +importlib-metadata==7.0.1 + # via + # flytekit + # keyring +iniconfig==2.0.0 + # via pytest +ipython==8.21.0 + # via -r dev-requirements.in +isodate==0.6.1 + # via azure-storage-blob +jaraco-classes==3.3.0 + # via + # keyring + # keyrings-alt +jedi==0.19.1 + # via ipython +jinja2==3.1.3 + # via + # cookiecutter + # torch +jmespath==1.0.1 + # via botocore +joblib==1.3.2 + # via + # -r dev-requirements.in + # flytekit + # scikit-learn +jsonpickle==3.0.2 + # via flytekit +keras==2.15.0 + # via tensorflow-macos +keyring==24.3.0 + # via flytekit +keyrings-alt==5.0.0 + # via -r dev-requirements.in +kubernetes==29.0.0 + # via flytekit +libclang==16.0.6 + # via tensorflow-macos +markdown==3.5.2 + # via tensorboard +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.5 + # via + # jinja2 + # werkzeug +marshmallow==3.20.2 + # via + # dataclasses-json + # marshmallow-enum + # marshmallow-jsonschema +marshmallow-enum==1.5.1 + # via + # dataclasses-json + # flytekit +marshmallow-jsonschema==0.13.0 + # via flytekit +mashumaro==3.12 + # via flytekit +matplotlib-inline==0.1.6 + # via ipython +mdurl==0.1.2 + # via markdown-it-py +ml-dtypes==0.2.0 + # via tensorflow-macos +mock==5.1.0 + # via -r dev-requirements.in +more-itertools==10.2.0 + # via jaraco-classes +mpmath==1.3.0 + # via sympy +msal==1.26.0 + # via + # azure-datalake-store + # azure-identity + # msal-extensions +msal-extensions==1.1.0 + # via azure-identity +multidict==6.0.5 + # via + # aiohttp + # yarl +mypy==1.6.1 + # via -r dev-requirements.in +mypy-extensions==1.0.0 + # via + # mypy + # typing-inspect +networkx==3.2.1 + # via torch +nodeenv==1.8.0 + # via pre-commit +numpy==1.26.4 + # via + # -r dev-requirements.in + # h5py + # ml-dtypes + # opt-einsum + # pandas + # pyarrow + # scikit-learn + # scipy + # tensorboard + # tensorflow-macos +oauthlib==3.2.2 + # via + # kubernetes + # requests-oauthlib +opt-einsum==3.3.0 + # via tensorflow +orjson==3.9.12 + # via -r dev-requirements.in +packaging==23.2 + # via + # docker + # google-cloud-bigquery + # marshmallow + # msal-extensions + # pytest + # setuptools-scm + # tensorflow-macos +pandas==2.2.0 + # via -r dev-requirements.in +parso==0.8.3 + # via jedi +pexpect==4.9.0 + # via ipython +pillow==10.2.0 + # via -r dev-requirements.in +platformdirs==4.2.0 + # via virtualenv +pluggy==1.4.0 + # via pytest +portalocker==2.8.2 + # via msal-extensions +pre-commit==3.6.0 + # via -r dev-requirements.in +prometheus-client==0.19.0 + # via -r dev-requirements.in +prompt-toolkit==3.0.43 + # via ipython +proto-plus==1.23.0 + # via google-cloud-bigquery-storage +protobuf==4.23.4 + # via + # flyteidl + # flytekit + # google-api-core + # google-cloud-bigquery-storage + # googleapis-common-protos + # grpcio-status + # proto-plus + # protoc-gen-swagger + # tensorboard + # tensorflow-macos +protoc-gen-swagger==0.1.0 + # via flyteidl +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pyarrow==15.0.0 + # via flytekit +pyasn1==0.5.1 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via google-auth +pycparser==2.21 + # via cffi +pyflakes==3.2.0 + # via autoflake +pygments==2.17.2 + # via + # ipython + # rich +pyjwt[crypto]==2.8.0 + # via + # msal + # pyjwt +pytest==7.4.4 + # via + # -r dev-requirements.in + # pytest-asyncio + # pytest-cov + # pytest-mock + # pytest-timeout + # pytest-xdist +pytest-asyncio==0.23.4 + # via -r dev-requirements.in +pytest-cov==4.1.0 + # via -r dev-requirements.in +pytest-mock==3.12.0 + # via -r dev-requirements.in +pytest-timeout==2.2.0 + # via -r dev-requirements.in +pytest-xdist==3.5.0 + # via -r dev-requirements.in +python-dateutil==2.8.2 + # via + # arrow + # botocore + # croniter + # google-cloud-bigquery + # kubernetes + # pandas +python-json-logger==2.0.7 + # via flytekit +python-magic==0.4.27 ; platform_system == "Darwin" or platform_system == "Linux" + # via -r dev-requirements.in +python-slugify==8.0.3 + # via cookiecutter +pytimeparse==1.1.8 + # via flytekit +pytz==2024.1 + # via + # croniter + # pandas +pyyaml==6.0.1 + # via + # cookiecutter + # flytekit + # kubernetes + # pre-commit +requests==2.31.0 + # via + # azure-core + # azure-datalake-store + # cookiecutter + # docker + # flytekit + # gcsfs + # google-api-core + # google-cloud-bigquery + # google-cloud-storage + # kubernetes + # msal + # requests-oauthlib + # tensorboard +requests-oauthlib==1.3.1 + # via + # google-auth-oauthlib + # kubernetes +rich==13.7.0 + # via + # cookiecutter + # flytekit + # rich-click +rich-click==1.7.3 + # via flytekit +rsa==4.9 + # via google-auth +s3fs==2023.9.2 + # via flytekit +scikit-learn==1.4.0 + # via -r dev-requirements.in +scipy==1.12.0 + # via scikit-learn +setuptools-scm==8.0.4 + # via -r dev-requirements.in +six==1.16.0 + # via + # asttokens + # astunparse + # azure-core + # google-pasta + # isodate + # kubernetes + # python-dateutil + # tensorboard + # tensorflow-macos +sortedcontainers==2.4.0 + # via hypothesis +stack-data==0.6.3 + # via ipython +statsd==3.3.0 + # via flytekit +sympy==1.12 + # via torch +tensorboard==2.15.1 + # via tensorflow-macos +tensorboard-data-server==0.7.2 + # via tensorboard +tensorflow==2.15.0 ; python_version < "3.12" + # via -r dev-requirements.in +tensorflow-estimator==2.15.0 + # via tensorflow-macos +tensorflow-io-gcs-filesystem==0.34.0 + # via tensorflow-macos +tensorflow-macos==2.15.0 + # via tensorflow +termcolor==2.4.0 + # via tensorflow-macos +text-unidecode==1.3 + # via python-slugify +threadpoolctl==3.2.0 + # via scikit-learn +torch==2.2.0 ; python_version < "3.12" + # via -r dev-requirements.in +traitlets==5.14.1 + # via + # ipython + # matplotlib-inline +types-croniter==2.0.0.20240106 + # via -r dev-requirements.in +types-mock==5.1.0.20240106 + # via -r dev-requirements.in +types-protobuf==4.24.0.20240129 + # via -r dev-requirements.in +types-python-dateutil==2.8.19.20240106 + # via arrow +types-requests==2.31.0.6 + # via -r dev-requirements.in +types-urllib3==1.26.25.14 + # via types-requests +typing-extensions==4.9.0 + # via + # azure-core + # azure-storage-blob + # flytekit + # mashumaro + # mypy + # rich-click + # setuptools-scm + # tensorflow-macos + # torch + # typing-inspect +typing-inspect==0.9.0 + # via dataclasses-json +tzdata==2023.4 + # via pandas +urllib3==1.26.18 + # via + # botocore + # docker + # flytekit + # kubernetes + # requests +virtualenv==20.25.0 + # via pre-commit +wcwidth==0.2.13 + # via prompt-toolkit +websocket-client==1.7.0 + # via + # docker + # kubernetes +werkzeug==3.0.1 + # via tensorboard +wheel==0.42.0 + # via astunparse +wrapt==1.14.1 + # via + # aiobotocore + # tensorflow-macos +yarl==1.9.4 + # via aiohttp +zipp==3.17.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/flytekit/docs/Makefile b/flytekit/docs/Makefile new file mode 100644 index 0000000000..ab89fb24cf --- /dev/null +++ b/flytekit/docs/Makefile @@ -0,0 +1,24 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = flytekit +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + + +clean: + rm -rf ./build ./source/generated ./source/plugins/generated diff --git a/flytekit/docs/make.bat b/flytekit/docs/make.bat new file mode 100644 index 0000000000..47d656bb74 --- /dev/null +++ b/flytekit/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build +set SPHINXPROJ=simpleble + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/flytekit/docs/source/_templates/custom.rst b/flytekit/docs/source/_templates/custom.rst new file mode 100644 index 0000000000..17c9b00963 --- /dev/null +++ b/flytekit/docs/source/_templates/custom.rst @@ -0,0 +1,42 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +{% if objtype == 'class' %} + +.. autoclass:: {{ objname }} + + {% block methods %} + {% if methods %} + + .. rubric:: {{ _('Methods') }} + {% for item in methods %} + + {% if item != '__init__' %} + .. automethod:: {{ item }} + {% endif %} + + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + + .. rubric:: {{ _('Attributes') }} + {% for item in attributes %} + .. autoattribute:: {{ item }} + :noindex: + {%- endfor %} + + {% endif %} + {% endblock %} + + +{% endif %} + +{% if objtype == 'function' %} + +.. autofunction:: {{ objname }} + +{% endif %} diff --git a/flytekit/docs/source/_templates/file_types.rst b/flytekit/docs/source/_templates/file_types.rst new file mode 100644 index 0000000000..e7629ea363 --- /dev/null +++ b/flytekit/docs/source/_templates/file_types.rst @@ -0,0 +1,39 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +{% if objname == 'FlyteFile' %} + +.. autoclass:: {{ objname }} + + {% block methods %} + {% if methods %} + + .. rubric:: {{ _('Methods') }} + {% for item in methods %} + + {% if item != '__init__' %} + .. automethod:: {{ item }} + {% endif %} + + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + + .. rubric:: {{ _('Attributes') }} + {% for item in attributes %} + .. autoattribute:: {{ item }} + {%- endfor %} + + {% endif %} + {% endblock %} + + +{% else %} + +.. autodata:: {{ objname }} + +{% endif %} diff --git a/flytekit/docs/source/_templates/sidebar/brand.html b/flytekit/docs/source/_templates/sidebar/brand.html new file mode 100644 index 0000000000..a170d6c6d1 --- /dev/null +++ b/flytekit/docs/source/_templates/sidebar/brand.html @@ -0,0 +1,18 @@ + diff --git a/flytekit/docs/source/clients.rst b/flytekit/docs/source/clients.rst new file mode 100644 index 0000000000..f67ebf6a3a --- /dev/null +++ b/flytekit/docs/source/clients.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.clients + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/conf.py b/flytekit/docs/source/conf.py new file mode 100644 index 0000000000..f86a8d2c06 --- /dev/null +++ b/flytekit/docs/source/conf.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/stable/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +flytekit_dir = os.path.abspath("../..") +flytekit_src_dir = os.path.abspath(os.path.join(flytekit_dir, "flytekit")) +plugins_dir = os.path.abspath(os.path.join(flytekit_dir, "plugins")) + +for possible_plugin_dir in os.listdir(plugins_dir): + dir_path = os.path.abspath((os.path.join(plugins_dir, possible_plugin_dir))) + plugin_path = os.path.abspath(os.path.join(dir_path, "flytekitplugins")) + if os.path.isdir(dir_path) and os.path.exists(plugin_path): + sys.path.insert(0, dir_path) + +sys.path.insert(0, flytekit_src_dir) +sys.path.insert(0, flytekit_dir) + +# -- Project information ----------------------------------------------------- + +project = "Flytekit" +copyright = "2023, Flyte" +author = "Flyte" + +# The full version, including alpha/beta/rc tags +release = "0.16.0b9" + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.autosectionlabel", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.coverage", + "sphinx.ext.inheritance_diagram", + "sphinx.ext.graphviz", + "sphinx-prompt", + "sphinx_copybutton", + "sphinx_panels", + "sphinx_reredirects", + "sphinxcontrib.youtube", + "sphinx_tags", + "sphinx_click", +] + +# build the templated autosummary files +autosummary_generate = True + +autodoc_typehints = "description" + +suppress_warnings = ["autosectionlabel.*"] + +# autosectionlabel throws warnings if section names are duplicated. +# The following tells autosectionlabel to not throw a warning for +# duplicated section names that are in different documents. +autosectionlabel_prefix_document = True + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ["docs_index.rst"] + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "furo" +html_title = "Flyte" + +announcement = """ +📢 This is the old documentation for Flyte. +Please visit the new documentation here. +""" + +html_theme_options = { + "light_css_variables": { + "color-brand-primary": "#4300c9", + "color-brand-content": "#4300c9", + "color-announcement-background": "#FEE7B8", + "color-announcement-text": "#535353", + }, + "dark_css_variables": { + "color-brand-primary": "#9D68E4", + "color-brand-content": "#9D68E4", + "color-announcement-background": "#493100", + }, + # custom flyteorg furo theme options + "github_repo": "flytekit", + "github_username": "flyteorg", + "github_commit": "master", + "docs_path": "docs/source", # path to documentation source + "announcement": announcement, +} + +templates_path = ["_templates"] + +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# html_sidebars = {"**": ["logo-text.html", "globaltoc.html", "localtoc.html", "searchbox.html"]} + + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +html_logo = "flyte_circle_gradient_1_4x4.png" +html_favicon = "flyte_circle_gradient_1_4x4.png" + +pygments_style = "tango" +pygments_dark_style = "native" + +html_context = { + "home_page": "https://docs.flyte.org", +} + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = "flytekitdoc" + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, "flytekit.tex", "Flytekit Documentation", "Flyte", "manual"), +] + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(master_doc, "flytekit", "Flytekit Documentation", [author], 1)] + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "flytekit", + "Flytekit Documentation", + author, + "flytekit", + "Python SDK for Flyte (https://flyte.org).", + "Miscellaneous", + ), +] + +# -- Extension configuration ------------------------------------------------- +# intersphinx configuration +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "flytectl": ("https://flytectl.readthedocs.io/en/latest/", None), + "idl": ("https://flyteidl.readthedocs.io/en/latest/", None), + # "flytectl": ("/Users/yourusername/go/src/github.com/flyteorg/flytectl/docs/build/html", None), + "cookbook": ("https://flytecookbook.readthedocs.io/en/latest/", None), + "flyte": ("https://flyte.readthedocs.io/en/latest/", None), +} + +inheritance_graph_attrs = { + "resolution": 300.0, +} + +inheritance_node_attrs = { + "bgcolor": "aliceblue", +} + +inheritance_edge_attrs = { + "color": "darkgray", +} + +autoclass_content = "both" + +# Tags config +tags_create_tags = True +tags_page_title = "Tag" +tags_overview_title = "All Tags" + + +# Sphinx redirects to the monodocs +page_pattern = "https://docs.flyte.org/en/latest/api/flytekit/$source.html" + +if int(os.environ.get("ENABLE_SPHINX_REDIRECTS", 0)): + redirects = { + "generated/*": page_pattern, + "design/*": page_pattern, + "plugins/*": page_pattern, + "flytekit.html": "https://docs.flyte.org/en/latest/api/flytekit/flytekit.html", + "configuration.html": "https://docs.flyte.org/en/latest/api/flytekit/configuration.html", + "remote.html": "https://docs.flyte.org/en/latest/api/flytekit/remote.html", + "clients.html": "https://docs.flyte.org/en/latest/api/flytekit/clients.html", + "testing.html": "https://docs.flyte.org/en/latest/api/flytekit/testing.html", + "extend.html": "https://docs.flyte.org/en/latest/api/flytekit/extend.html", + "deck.html": "https://docs.flyte.org/en/latest/api/flytekit/deck.html", + "tasks*": page_pattern, + "types*": page_pattern, + "extras*": page_pattern, + "experimental.html": "https://docs.flyte.org/en/latest/api/flytekit/experimental.html", + "pyflyte.html": "https://docs.flyte.org/en/latest/api/flytekit/pyflyte.html", + "contributing.html": "https://docs.flyte.org/en/latest/api/flytekit/contributing.html", + } diff --git a/flytekit/docs/source/configuration.rst b/flytekit/docs/source/configuration.rst new file mode 100644 index 0000000000..b67271af1e --- /dev/null +++ b/flytekit/docs/source/configuration.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.configuration + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/contributing.rst b/flytekit/docs/source/contributing.rst new file mode 100644 index 0000000000..a7295c5bb6 --- /dev/null +++ b/flytekit/docs/source/contributing.rst @@ -0,0 +1,167 @@ +.. _contributing: + +########################### +Flytekit Contribution Guide +########################### + +.. tags:: Contribute, Basic + +First off, thank you for thinking about contributing! Below you'll find instructions that will hopefully guide you through how to fix, improve, and extend Flytekit. + +Please also take some time to read through the :std:ref:`design guides `, which describe the various parts of Flytekit and should make contributing easier. + +******************* +📜 Background +******************* + +Below is a listing of the most important packages that comprise the flytekit SDK: + +- ``flytekit/core`` + This holds all the core functionality of the new API. +- ``flytekit/types`` + We bundle some special types like ``FlyteFile, FlyteSchema etc`` by default here. +- ``flytekit/extend`` + This is the future home of extension points, and currently serves as the raw documentation for extensions. +- ``flytekit/extras`` + This contains code that we want bundled with Flytekit but not everyone may find useful (for example AWS and GCP + specific logic). +- ``flytekit/remote`` + This implements the interface to interact with the Flyte service. Think of the code here as the Python-object version of Console. +- ``flytekit/testing`` + is the future home for testing functionality like ``mock`` etc, and currently serves as documentation. + All test extensions should be imported from here. +- ``flytekit/models`` + Protobuf generated Python code is not terribly user-friendly, so we improve upon those ``flyteidl`` classes here. +- ``plugins`` + is the source of all plugins +- ``flytekit/bin/entrypoint.py`` + The run time entrypoint for flytekit. When a task kicks off, this is where the click command goes. +- ``flytekit/clis`` + This is the home for the CLIs. +- ``flytekit/configuration`` + This holds all the configuration objects, but dependency on configuration should be carefully considered as it + makes compiled Flyte tasks and workflows less portable (i.e. if you run ``pyflyte package`` can someone else use + those serialized objects). + +Please also see the :std:ref:`design overview section ` for more in-depth information. + + +****************** +💻 Contribute Code +****************** + +Setup (Do Once) +=============== + +We recommend using a virtual environment to develop Flytekit. Inside the top level Flytekit repo folder, run: :: + + virtualenv ~/.virtualenvs/flytekit + source ~/.virtualenvs/flytekit/bin/activate + make setup + +This will install Flytekit dependencies and Flytekit in editable mode. This links your virtual Python's ``site-packages`` with your local repo folder, allowing your local changes to take effect when the same Python interpreter runs ``import flytekit``. + +Plugin Development +================== + +As discussed in the design component, Flytekit plugins currently live in this Flytekit repo, but under a different top level folder ``plugins``. +In the future, this will be separated out into a different repo. These plugins follow a `microlib `__ structure, which will persist even if we move repos. :: + + source ~/.virtualenvs/flytekit/bin/activate + cd plugins + pip install -e . + +This should install all the plugins in editable mode as well. + +Iteration +========= + +Make +^^^^ +Some helpful make commands :: + + $ make + setup Install requirements + fmt Format code with ruff + lint Run linters + test Run tests + requirements Compile requirements + +Testing +^^^^^^^ +Three levels of testing are available. + +Unit Testing +------------ +Running unit tests: :: + + source ~/.virtualenvs/flytekit/bin/activate + make test + +Cookbook Testing +---------------- +Please see the `cookbook `__ and the generated `docs `__ for more information. +This example repo can be cloned and run on a local Flyte cluster, or just in your IDE or other Python environment. + +Follow the setup instructions for the cookbook and then override it with the version of Flytekit you're interested in testing by running something like: :: + + pip install https://github.com/flyteorg/flytekit/archive/a32ab82bef4d9ff53c2b7b4e69ff11f1e93858ea.zip#egg=flytekit + # Or for a plugin + pip install https://github.com/flyteorg/flytekit/archive/e128f66dda48bbfc6076d240d39e4221d6af2d2b.zip#subdirectory=plugins/pod&egg=flytekitplugins-pod + +Change the actual link to be from your fork if you are using a fork. + +End-to-end Testing +------------------ + +.. TODO: Replace this with actual instructions + +The Flyte developer experience team has put together an end-to-end testing framework that will spin up a K8s cluster, install Flyte onto it, and run through a series of workflows. +Please contact us if you reach this stage and would like more information on this. + + +Pre-commit hooks +================ + +We use `pre-commit `__ to automate linting and code formatting on every commit. +Configured hooks include `ruff `__ and also linters to check for the validity of YAML files and ensuring that newlines are added to the end of files. + +We run all those hooks in CI, but if you want to run them locally on every commit, run `pre-commit install` after installing the dev environment requirements. In case you want to disable `pre-commit` hooks locally, for example, while you're iterating on some feature, run `pre-commit uninstall`. More info in https://pre-commit.com/. + + +Formatting +========== + +We use `ruff `__ to autoformat code. In fact, they have been configured as git hooks in `pre-commit`. Run the following commands to execute the formatters. :: + + source ~/.virtualenvs/flytekit/bin/activate + make fmt + +Spell-checking +============== + +We use `codespell `__ to catch spelling mistakes in both code and documentation. Run the following commands to spell-check changes. :: + + source ~/.virtualenvs/flytekit/bin/activate + make spellcheck + +****************************** +📃 Contribute to Documentation +****************************** + +1. Install requirements by running ``make doc-requirements.txt`` in the root of the repo +2. Make the required changes +3. Verify if the documentation looks as expected by running ``make html`` in the `docs `__ directory +4. Open HTML pages present in the ``docs/build`` directory in the browser +5. After creating the pull request, check if the docs are rendered correctly by clicking on the documentation check + + .. image:: https://raw.githubusercontent.com/flyteorg/static-resources/main/common/test_docs_link.png + :alt: Doc link in PR + +********************************** +📝 Releases and Project Management +********************************** + +Currently, Flytekit and all its plugins share one common version. +To release, contact a member of the Flytekit repo maintainers or committers, and request a release. +We will create a GitHub release off of master, which will automatically publish a Pypi package. diff --git a/flytekit/docs/source/deck.rst b/flytekit/docs/source/deck.rst new file mode 100644 index 0000000000..43159c51f4 --- /dev/null +++ b/flytekit/docs/source/deck.rst @@ -0,0 +1,5 @@ + +.. automodule:: flytekit.deck + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/design/authoring.rst b/flytekit/docs/source/design/authoring.rst new file mode 100644 index 0000000000..3118a9c71b --- /dev/null +++ b/flytekit/docs/source/design/authoring.rst @@ -0,0 +1,161 @@ +.. _design-authoring: + +################### +Authoring Structure +################### + +.. tags:: Design, Basic + +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 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 +***************** + +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 +===== + +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 + :top-classes: flytekit.core.base_task.Task + :parts: 1 + +For more information on each of the classes, please refer to the corresponding documentation. + +.. autoclass:: flytekit.core.base_task.Task + :noindex: + +.. autoclass:: flytekit.core.base_task.PythonTask + :noindex: + +.. autoclass:: flytekit.core.python_auto_container.PythonAutoContainerTask + :noindex: + +.. autoclass:: flytekit.core.python_function_task.PythonFunctionTask + :noindex: + +Workflows +========== + +There exist two workflow classes, both of which derive from the ``WorkflowBase`` class. + +.. autoclass:: flytekit.core.workflow.PythonFunctionWorkflow + :noindex: + +.. autoclass:: flytekit.core.workflow.ImperativeWorkflow + :noindex: + +Launch Plans +============ + +There exists one :py:class:`LaunchPlan ` class. + +.. autoclass:: flytekit.core.launch_plan.LaunchPlan + :noindex: + +.. _exception_handling: + +****************** +Exception Handling +****************** + +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. + +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 + :top-classes: Exception + +Implementation +============== + +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 entities mentioned above (tasks, workflows, and launch plans) are callable and can be invoked to generate one or more units of work in Flyte. + +In Pythonic terminology, adding ``()`` to the end of an entity invokes the ``__call__`` method on the object. + +The behavior that occurs when a callable entity is invoked is dependent on the current context, specifically the current :py:class:`flytekit.FlyteContext`. + +Raw task execution +================== + +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: + +Consider the following workflow: :: + + @task + def t1(a: int) -> Tuple[int, str]: + return a + 2, "world" + + @task + def t2(a: str, b: str) -> str: + return b + a + + @workflow + def my_wf(a: int, b: str) -> Tuple[int, str]: + x, y = t1(a=a).with_overrides(...) + d = t2(a=y, b=b) + return x, d + +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 invoked, the values are extracted from these Promises. + +Compilation +=========== + +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 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:: + + The execution pattern that we discussed for tasks can be applied to workflows and launch plans as well! diff --git a/flytekit/docs/source/design/clis.rst b/flytekit/docs/source/design/clis.rst new file mode 100644 index 0000000000..32ba6e9edb --- /dev/null +++ b/flytekit/docs/source/design/clis.rst @@ -0,0 +1,101 @@ +.. _design-clis: + +################################### +Command Line Interfaces and Clients +################################### + +.. tags:: CLI, Basic + +Flytekit currently ships with two CLIs, both of which rely on the same client implementation code. + +******* +Clients +******* +The client code is located in ``flytekit/clients`` and there are two. + +* Similar to the :ref:`design-models` files, but a bit more complex, the ``raw`` one is basically a wrapper around the protobuf generated code, with some handling for authentication in place, and acts as a mechanism for autocompletion and comments. +* The ``friendly`` client uses the ``raw`` client, adds handling of things like pagination, and is structurally more aligned with the functionality and call pattern of the CLI itself. + +:py:class:`clients.friendly.SynchronousFlyteClient` + +:py:class:`clients.raw.RawSynchronousFlyteClient` + +*********************** +Command Line Interfaces +*********************** + +Flytectl +========= + +`Flytectl `__ is the general CLI to communicate with the Flyte control plane (FlyteAdmin). Think of this as the ``kubectl`` for Flyte. + +Think of this as a network-aware (can talk to FlyteAdmin) but not code-aware (no need to have user code checked out) CLI. In the registration flow, this CLI is responsible for shipping the compiled Protobuf files off to FlyteAdmin. + +Pyflyte +======== + +Unlike ``flytectl``, think of this CLI as code-aware, which is responsible for the serialization (compilation) step in the registration flow. It will parse through the user code, looking for tasks, workflows, and launch plans, and compile them to `protobuf files `__. + +.. _pyflyte-run: + +What is ``pyflyte run``? +======================== + +The ``pyflyte run`` command is a light-weight, convenience command that incorporates packaging, registering, and launching a workflow into a single command. + +It is not a fully featured production scale mode of operation, because it is designed to be a quick and easy iteration tool to get started with Flyte or test small self-contained scripts. The caveat here is it operates on a single file, and this file will have to contain all the required Flyte entities. Let’s take an example so that you can understand it better. + +Suppose you execute a script that defines 10 tasks and a workflow that calls only 2 out of the 10 tasks. The remaining 8 tasks don’t get registered at that point. + +It is considered fast registration because when a script is executed using ``pyflyte run``, the script is bundled up and uploaded to FlyteAdmin. When the task is executed in the backend, this zipped file is extracted and used. + +.. _pyflyte-register: + +What is ``pyflyte register``? +============================= + +``pyflyte register`` is a command that registers all the workflows present in the repository/directory using fast-registration. It is equivalent to using two commands (``pyflyte package`` and ``flytectl register``) to perform the same operation (registration). It compiles the Python code into protobuf objects and uploads the files directly to FlyteAdmin. In the process, the protobuf objects are not written to the local disk, making it difficult to introspect these objects since they are lost. + +The ``pyflyte package`` command parses and compiles the user’s Python code into Flyte protobuf objects. These compiled objects are stored as protobuf files and are available locally after you run the ``pyflyte package``. + +The ``flytectl register`` command then ships the protobuf objects over the network to the Flyte control plane. In the process, ``flytectl`` also allows you to set run-time attributes such as IAM roles, K8s service accounts, etc. + +``pyflyte package + flytectl register`` produces a **portable** package (a .tgz file) of Flyte entities (compiled protobuf files that are stored on the local disk), which makes it easy to introspect the objects at a later time if required. You can use register this package with multiple Flyte backends. You can save this package, use it for an audit, register with different FlyteAdmins, etc. + +Why should you use ``pyflyte register``? +======================================== + +The ``pyflyte register`` command bridges the gap between ``pyflyte package`` + ``flytectl register`` and ``pyflyte run`` commands. It offers the functionality of the ``pyflyte package`` (with smarter naming semantics and combining the network call into one step). + +.. note :: + + You can't use ``pyflyte register`` if you are unaware of the run-time options yet (IAM role, service account, and so on). + +Usage +===== + +.. prompt:: bash $ + + pyflyte register --image ghcr.io/flyteorg/flytecookbook:core-latest --image trainer=ghcr.io/flyteorg/flytecookbook:core-latest --image predictor=ghcr.io/flyteorg/flytecookbook:core-latest --raw-data-prefix s3://development-service-flyte/reltsts flyte_basics + +In a broad way, ``pyflyte register`` is equivalent to ``pyflyte run`` minus launching workflows, with the exception that ``pyflyte run`` can only register a single workflow, whereas ``pyflyte register`` can register all workflows in a repository. + +What is the difference between ``pyflyte package + flytectl register`` and ``pyflyte register``? +================================================================================================ + +``pyflyte package + flytectl register`` works well with multiple FlyteAdmins since it produces a portable package. You can also use it to run scripts in CI. + +``pyflyte register`` works well in single FlyteAdmin use-cases and cases where you are iterating locally. + +Should you use ``pyflyte run`` or ``pyflyte package + flytectl register``? +========================================================================== + +Both the commands have their own place in a production Flyte setting. + +``pyflyte run`` is useful when you are getting started with Flyte, testing small scripts, or iterating over local scripts. + +``pyflyte package + flytectl register`` is useful when you wish to work with multiple FlyteAdmins, wherein you can package the script, compile it into protobuf objects, write it to local disk, and upload this zipped package to different FlyteAdmins. + +.. note :: + + Neither ``pyflyte register`` nor ``pyflyte run`` commands work on Python namespace packages since both the tools traverse the filesystem to find the first folder that doesn't have an __init__.py file, which is interpreted as the root of the project. Both the commands use this root as the basis to name the Flyte entities. diff --git a/flytekit/docs/source/design/control_plane.rst b/flytekit/docs/source/design/control_plane.rst new file mode 100644 index 0000000000..e05be52129 --- /dev/null +++ b/flytekit/docs/source/design/control_plane.rst @@ -0,0 +1,347 @@ +.. _design-control-plane: + +################################################### +FlyteRemote: A Programmatic Control Plane Interface +################################################### + +.. tags:: Remote, Basic + +For those who require programmatic access to the control plane, the :mod:`~flytekit.remote` module enables you to perform +certain operations in a Python runtime environment. + +Since this section naturally deals with the control plane, this discussion is only relevant for those who have a Flyte +backend set up and have access to it (a local demo cluster will suffice as well). + +***************************** +Creating a FlyteRemote Object +***************************** + +The :class:`~flytekit.remote.remote.FlyteRemote` class is the entrypoint for programmatically performing operations in a Python +runtime. It can be initialized by passing in the: + +* :py:class:`~flytekit.configuration.Config` object: the parent configuration object that holds all the configuration information to connect to the Flyte backend. +* :py:attr:`~flytekit.remote.remote.FlyteRemote.default_project`: the default project to use when fetching or executing flyte entities. +* :py:attr:`~flytekit.remote.remote.FlyteRemote.default_domain`: the default domain to use when fetching or executing flyte entities. +* :py:attr:`~flytekit.remote.remote.FlyteRemote.file_access`: the file access provider to use for offloading non-literal inputs/outputs. +* ``kwargs``: additional arguments that need to be passed to create ``SynchronousFlyteClient``. + +A :class:`~flytekit.remote.remote.FlyteRemote` object can be created in various ways: + +Auto +==== + +The :py:class:`~flytekit.configuration.Config` class's :py:meth:`~flytekit.configuration.Config.auto` method can be used to automatically +construct the ``Config`` object. + +.. code-block:: python + + from flytekit.remote import FlyteRemote + from flytekit.configuration import Config + + remote = FlyteRemote(config=Config.auto()) + +``auto`` also accepts a ``config_file`` argument, which is the path to the configuration file to use. +The order of precedence that ``auto`` follows is: + +* Finds all the environment variables that match the configuration variables. +* If no environment variables are set, it looks for a configuration file at the path specified by the ``config_file`` argument. +* If no configuration file is found, it uses the default values. + +Sandbox +======= + +The :py:class:`~flytekit.configuration.Config` class's :py:meth:`~flytekit.configuration.Config.for_sandbox` method can be used to +construct the ``Config`` object, specifically to connect to the Flyte cluster. + +.. code-block:: python + + from flytekit.remote import FlyteRemote + from flytekit.configuration import Config + + remote = FlyteRemote(config=Config.for_sandbox()) + +The initialization is as simple as calling ``for_sandbox()`` on the ``Config`` class! +This, by default, uses ``localhost:30081`` as the endpoint, and the default minio credentials. + +If the sandbox is in a hosted-like environment, then *port-forward* or *ingress URLs* need to be taken care of. + +Any Endpoint +============ + +The :py:class:`~flytekit.configuration.Config` class's :py:meth:`~flytekit.configuration.Config.for_endpoint` method can be used to +construct the ``Config`` object to connect to a specific endpoint. + +.. code-block:: python + + from flytekit.remote import FlyteRemote + from flytekit.configuration import Config + + remote = FlyteRemote( + config=Config.for_endpoint(endpoint="flyte.example.net"), + default_project="flytesnacks", + default_domain="development", + ) + +The ``for_endpoint`` method also accepts: + +* ``insecure``: whether to use insecure connections. Defaults to ``False``. +* ``data_config``: can be used to configure how data is downloaded or uploaded to a specific blob storage like S3, GCS, etc. +* ``config_file``: the path to the configuration file to use. + +.. _general_initialization: + +Generalized Initialization +========================== + +The :py:class:`~flytekit.configuration.Config` class can be directly used to construct the ``Config`` object if additional configuration is needed. +You can send :py:class:`~flytekit.configuration.PlatformConfig`, :py:class:`~flytekit.configuration.DataConfig`, +:py:class:`~flytekit.configuration.SecretsConfig`, and :py:class:`~flytekit.configuration.StatsConfig` objects to the ``Config`` class. + +.. list-table:: ``Config`` Attributes + :widths: 50 50 + + * - ``PlatformConfig`` + - Settings to talk to a Flyte backend. + * - ``DataConfig`` + - Any data storage specific configuration. + * - ``SecretsConfig`` + - Configuration for secrets. + * - ``StatsConfig`` + - Configuration for sending statsd. + +For example: + +.. code-block:: python + + from flytekit.remote import FlyteRemote + from flytekit.configuration import Config, PlatformConfig + + remote = FlyteRemote( + config=Config( + platform=PlatformConfig( + endpoint="flyte.example.net", + insecure=False, + client_id="my-client-id", + client_credentials_secret="my-client-secret", + auth_mode="client_credentials", + ), + secrets=SecretsConfig(default_dir="/etc/secrets"), + ) + ) + +***************** +Fetching Entities +***************** + +Tasks, workflows, launch plans, and executions can be fetched using FlyteRemote. + +.. code-block:: python + + flyte_task = remote.fetch_task(name="my_task", version="v1") + flyte_workflow = remote.fetch_workflow(name="my_workflow", version="v1") + flyte_launch_plan = remote.fetch_launch_plan(name="my_launch_plan", version="v1") + flyte_execution = remote.fetch_execution(name="my_execution") + +``project`` and ``domain`` can also be specified in all the ``fetch_*`` calls. +If not specified, the default values given during the creation of the FlyteRemote object will be used. + +The following is an example that fetches :py:func:`~flytekit.task`s and creates a :py:func:`~flytekit.workflow`: + +.. code-block:: python + + from flytekit import workflow + + task_1 = remote.fetch_task(name="core.basic.hello_world.say_hello", version="v1") + task_2 = remote.fetch_task( + name="core.basic.lp.greet", + version="v13", + project="flytesnacks", + domain="development", + ) + + + @workflow + def my_remote_wf(name: str) -> int: + return task_2(task_1(name=name)) + +Another example that dynamically creates a launch plan for the ``my_remote_wf`` workflow: + +.. code-block:: python + + from flytekit import LaunchPlan + + flyte_workflow = remote.fetch_workflow( + name="my_workflow", version="v1", project="flytesnacks", domain="development" + ) + launch_plan = LaunchPlan.get_or_create(name="my_launch_plan", workflow=flyte_workflow) + +******************** +Registering Entities +******************** + +Tasks, workflows, and launch plans can be registered using FlyteRemote. + +.. code-block:: python + + from flytekit.configuration import SerializationSettings + + flyte_entity = ... + flyte_task = remote.register_task( + entity=flyte_entity, + serialization_settings=SerializationSettings(image_config=None), + version="v1", + ) + flyte_workflow = remote.register_workflow( + entity=flyte_entity, + serialization_settings=SerializationSettings(image_config=None), + version="v1", + ) + flyte_launch_plan = remote.register_launch_plan(entity=flyte_entity, version="v1") + +* ``entity``: the entity to register. +* ``version``: the version that will be used to register. If not specified, the version used in serialization settings will be used. +* ``serialization_settings``: the serialization settings to use. Refer to :py:class:`~flytekit.configuration.SerializationSettings` to know all the acceptable parameters. + +All the additional parameters which can be sent to the ``register_*`` methods can be found in the documentation for the corresponding method: +:py:meth:`~flytekit.remote.remote.FlyteRemote.register_task`, :py:meth:`~flytekit.remote.remote.FlyteRemote.register_workflow`, +and :py:meth:`~flytekit.remote.remote.FlyteRemote.register_launch_plan`. + +The :py:class:`~flytekit.configuration.SerializationSettings` class accepts :py:class:`~flytekit.configuration.ImageConfig` which +holds the available images to use for the registration. + +The following example showcases how to register a workflow using an existing image if the workflow is created locally: + +.. code-block:: python + + from flytekit.configuration import ImageConfig + + img = ImageConfig.from_images( + "docker.io/xyz:latest", {"spark": "docker.io/spark:latest"} + ) + wf2 = remote.register_workflow( + my_remote_wf, + serialization_settings=SerializationSettings(image_config=img), + version="v1", + ) + +****************** +Executing Entities +****************** + +You can execute a task, workflow, or launch plan using :meth:`~flytekit.remote.remote.FlyteRemote.execute` method +which returns a :class:`~flytekit.remote.executions.FlyteWorkflowExecution` object. +For more information on Flyte entities, see the :ref:`remote flyte entities ` reference. + +.. code-block:: python + + flyte_entity = ... # one of FlyteTask, FlyteWorkflow, or FlyteLaunchPlan + execution = remote.execute( + flyte_entity, inputs={...}, execution_name="my_execution", wait=True + ) + +* ``inputs``: the inputs to the entity. +* ``execution_name``: the name of the execution. This is useful to avoid de-duplication of executions. +* ``wait``: synchronously wait for the execution to complete. + +Additional arguments include: + +* ``project``: the project on which to execute the entity. +* ``domain``: the domain on which to execute the entity. +* ``type_hints``: a dictionary mapping Python types to their corresponding Flyte types. +* ``options``: options can be configured for a launch plan during registration or overridden during execution. Refer to :py:class:`~flytekit.remote.remote.Options` to know all the acceptable parameters. + +The following is an example demonstrating how to use the :py:class:`~flytekit.remote.remote.Options` class to configure a Flyte entity: + +.. code-block:: python + + from flytekit.models.common import AuthRole, Labels + from flytekit.tools.translator import Options + + flyte_entity = ... # one of FlyteTask, FlyteWorkflow, or FlyteLaunchPlan + execution = remote.execute( + flyte_entity, + inputs={...}, + execution_name="my_execution", + wait=True, + options=Options( + raw_data_prefix="s3://my-bucket/my-prefix", + auth_role=AuthRole(assumable_iam_role="my-role"), + labels=Labels({"my-label": "my-value"}), + ), + ) + +********************************** +Retrieving & Inspecting Executions +********************************** + +After an execution is completed, you can retrieve the execution using the :meth:`~flytekit.remote.remote.FlyteRemote.fetch_execution` method. +The fetched execution can be used to retrieve the inputs and outputs of an execution. + +.. code-block:: python + + execution = remote.fetch_execution( + name="fb22e306a0d91e1c6000", project="flytesnacks", domain="development" + ) + input_keys = execution.inputs.keys() + output_keys = execution.outputs.keys() + +The ``inputs`` and ``outputs`` correspond to the top-level execution or the workflow itself. + +To fetch a specific output, say, a model file: + +.. code-block:: python + + model_file = execution.outputs["model_file"] + with open(model_file) as f: + # use mode + ... + +You can use :meth:`~flytekit.remote.remote.FlyteRemote.sync` to sync the entity object's state with the remote state during the execution run: + +.. code-block:: python + + synced_execution = remote.sync(execution, sync_nodes=True) + node_keys = synced_execution.node_executions.keys() + +.. note:: + + During the sync, you may come across ``Received message larger than max (xxx vs. 4194304)`` error if the message size is too large. In that case, edit the ``flyte-admin-base-config`` config map using the command ``kubectl edit cm flyte-admin-base-config -n flyte`` to increase the ``maxMessageSizeBytes`` value. Refer to the :ref:`troubleshooting guide ` in case you've queries about the command's usage. + +``node_executions`` will fetch all the underlying node executions recursively. + +To fetch output of a specific node execution: + +.. code-block:: python + + node_execution_output = synced_execution.node_executions["n1"].outputs["model_file"] + +:ref:`Node ` here, can correspond to a task, workflow, or branch node. + +**************** +Listing Entities +**************** + +To list the recent executions, use the :meth:`~flytekit.remote.remote.FlyteRemote.recent_executions` method. + +.. code-block:: python + + recent_executions = remote.recent_executions(project="flytesnacks", domain="development", limit=10) + +The ``limit`` parameter is optional and defaults to 100. + +To list tasks by version, use the :meth:`~flytekit.remote.remote.FlyteRemote.list_tasks_by_version` method. + +.. code-block:: python + + tasks = remote.list_tasks_by_version(project="flytesnacks", domain="development", version="v1") + +************************ +Terminating an Execution +************************ + +To terminate an execution, use the :meth:`~flytekit.remote.remote.FlyteRemote.terminate` method. + +.. code-block:: python + + execution = remote.fetch_execution(name="fb22e306a0d91e1c6000", project="flytesnacks", domain="development") + remote.terminate(execution, cause="Code needs to be updated") diff --git a/flytekit/docs/source/design/execution.rst b/flytekit/docs/source/design/execution.rst new file mode 100644 index 0000000000..22683fb8b2 --- /dev/null +++ b/flytekit/docs/source/design/execution.rst @@ -0,0 +1,23 @@ +.. _design-execution: + +####################### +Execution Time Support +####################### + +.. tags:: Design, Basic + +Most of the tasks that are written in Flytekit will be Python functions decorated with ``@task`` which turns the body of the function into a Flyte task, capable of being run independently, or included in any number of workflows. The interaction between Flytekit and these tasks do not end once they have been serialized and registered onto the Flyte control plane however. When compiled, the command that will be executed when the task is run is hardcoded into the task definition itself. + +In the basic ``@task`` decorated function scenario, the command to be run will be something containing ``pyflyte-execute``, which is one of the CLIs discussed in that section. + +That command, if you were to inspect a serialized task, might look something like :: + + flytekit_venv pyflyte-execute --task-module app.workflows.failing_workflows --task-name divider --inputs {{.input}} --output-prefix {{.outputPrefix}} --raw-output-data-prefix {{.rawOutputDataPrefix}} + +The point of running this script, or rather the reason for having any Flyte-related logic at execution time, is purely to codify and streamline the interaction between Flyte the platform, and the function body comprising user code. The Flyte CLI is responsible for: + +* I/O: The templated ``--inputs`` and ``--output-prefix`` arguments in the example command above will be filled in by the Flyte execution engine with S3 path (in the case of an AWS deployment). The ``pyflyte`` script will download the inputs to the right location in the container, and upload the results to the ``output-prefix`` location. +* Ensure that raw output data prefix configuration option, which is again filled in by the Flyte engine, is respected so that ``FlyteFile``, ``FlyteDirectory``, and ``FlyteSchema`` objects offload their data to the correct place. +* Capture and handle error reporting: Exceptions thrown in the course of task execution are captured and uploaded to the Flyte control plane for display on the Console. +* Set up helper utilities like the ``statsd`` handle, logging and logging levels, etc. +* Ensure configuration options about the Flyte backend, which are passed through by the Flyte engine, are properly loaded in Python memory. diff --git a/flytekit/docs/source/design/index.rst b/flytekit/docs/source/design/index.rst new file mode 100644 index 0000000000..1539baa3a1 --- /dev/null +++ b/flytekit/docs/source/design/index.rst @@ -0,0 +1,24 @@ +.. _design: + +######## +Overview +######## + +Flytekit is comprised of a handful of different logical components, each discusssed in greater detail below: + +* :ref:`Models Files ` - These are almost Protobuf generated files. +* :ref:`Authoring ` - This provides the core Flyte authoring experiences, allowing users to write tasks, workflows, and launch plans. +* :ref:`Control Plane ` - The code here allows users to interact with the control plane through Python objects. +* :ref:`Execution ` - A small shim layer basically that handles interaction with the Flyte ecosystem at execution time. +* :ref:`CLIs and Clients ` - Command line tools users may interact with, and the control plane client the CLIs call. + +.. toctree:: + :maxdepth: 1 + :caption: Structure and Layout of Flytekit + :hidden: + + models + authoring + Control Plane: FlyteRemote + execution + clis diff --git a/flytekit/docs/source/design/models.rst b/flytekit/docs/source/design/models.rst new file mode 100644 index 0000000000..7e92d77dae --- /dev/null +++ b/flytekit/docs/source/design/models.rst @@ -0,0 +1,42 @@ +.. _design-models: + +########### +Model Files +########### + +.. tags:: Design, Basic + +*********** +Description +*********** +This section deals with the files in `models `__ folder. +These files are better formatted versions of the generated Python code from `Flyte IDL `__. In the future, we hope to be able to improve the Python code generator sufficiently to avoid this manual work. + +The __only__ reason these files exist is only because the Protobuf generated Python code doesn't work well with IDEs. It doesn't +offer code completion, doesn't offer argument completion, docstrings, etc. + +The structure of the code in the models folder should mirror the folder structure of the IDL. There are a few instances +where this is not the case but this is incidental and we'll work on resolving these. + +********* +Structure +********* + +It may be helpful to take a look at an example like the ``TaskTemplate`` `here `__ + +Constructor +=========== +The constructor should allow specification of all the elements of the Protobuf object. They should be stored as an attribute with a leading underscore. + +Properties +========== +Each element of the IDL message should be exposed as a property. + +IDL Interaction +=============== +Each Python object should have a ``to_flyte_idl`` and a ``from_flyte_idl`` function that converts between the Python model class and the Protobuf-generated Python class. + +********* +Testing +********* +Please add unit tests to the ``tests/flytekit/unit/models`` folder to test the conversion logic. diff --git a/flytekit/docs/source/docs_index.rst b/flytekit/docs/source/docs_index.rst new file mode 100644 index 0000000000..dbbf95af83 --- /dev/null +++ b/flytekit/docs/source/docs_index.rst @@ -0,0 +1,22 @@ +********************** +Flytekit API Reference +********************** + +.. toctree:: + :maxdepth: 2 + + design/index + flytekit + configuration + remote + clients + testing + extras.accelerators + extend + deck + plugins/index + tasks.extend + types.extend + experimental + pyflyte + contributing diff --git a/flytekit/docs/source/experimental.rst b/flytekit/docs/source/experimental.rst new file mode 100644 index 0000000000..50d6c42ad9 --- /dev/null +++ b/flytekit/docs/source/experimental.rst @@ -0,0 +1,16 @@ +Experimental Features +===================== + +.. currentmodule:: flytekit + +.. important:: + + The constructs below are experimental and the API is subject to breaking changes. + +.. autosummary:: + :nosignatures: + :toctree: generated/ + + ~experimental.map_task + ~experimental.eager + ~experimental.EagerException diff --git a/flytekit/docs/source/extend.rst b/flytekit/docs/source/extend.rst new file mode 100644 index 0000000000..40333d5a3e --- /dev/null +++ b/flytekit/docs/source/extend.rst @@ -0,0 +1,5 @@ + +.. automodule:: flytekit.extend + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/extras.accelerators.rst b/flytekit/docs/source/extras.accelerators.rst new file mode 100644 index 0000000000..2655200a23 --- /dev/null +++ b/flytekit/docs/source/extras.accelerators.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.extras.accelerators + :members: + :undoc-members: + :show-inheritance: diff --git a/flytekit/docs/source/extras.pytorch.rst b/flytekit/docs/source/extras.pytorch.rst new file mode 100644 index 0000000000..0f51bf219d --- /dev/null +++ b/flytekit/docs/source/extras.pytorch.rst @@ -0,0 +1,10 @@ +############ +PyTorch Type +############ + +.. tags:: MachineLearning, Basic + +.. automodule:: flytekit.extras.pytorch + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/extras.sklearn.rst b/flytekit/docs/source/extras.sklearn.rst new file mode 100644 index 0000000000..a2efcfa84b --- /dev/null +++ b/flytekit/docs/source/extras.sklearn.rst @@ -0,0 +1,7 @@ +############ +Sklearn Type +############ +.. automodule:: flytekit.extras.sklearn + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/extras.sqlite3.rst b/flytekit/docs/source/extras.sqlite3.rst new file mode 100644 index 0000000000..f1a1174480 --- /dev/null +++ b/flytekit/docs/source/extras.sqlite3.rst @@ -0,0 +1,10 @@ +############ +SQLite3 Task +############ + +.. tags:: SQL, Basic + +.. automodule:: flytekit.extras.sqlite3 + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/extras.tasks.rst b/flytekit/docs/source/extras.tasks.rst new file mode 100644 index 0000000000..f25e607b23 --- /dev/null +++ b/flytekit/docs/source/extras.tasks.rst @@ -0,0 +1,8 @@ +########## +Shell Task +########## + +.. automodule:: flytekit.extras.tasks + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/extras.tensorflow.rst b/flytekit/docs/source/extras.tensorflow.rst new file mode 100644 index 0000000000..b5984c0dc9 --- /dev/null +++ b/flytekit/docs/source/extras.tensorflow.rst @@ -0,0 +1,8 @@ +############### +TensorFlow Type +############### + +.. automodule:: flytekit.extras.tensorflow + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/flyte_circle_gradient_1_4x4.png b/flytekit/docs/source/flyte_circle_gradient_1_4x4.png new file mode 100644 index 0000000000000000000000000000000000000000..49cdbbbc3419c1c65ceeb3f11596762e0c60faa4 GIT binary patch literal 70985 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

o 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 literal 0 HcmV?d00001 diff --git a/flytekit/docs/source/flytekit.rst b/flytekit/docs/source/flytekit.rst new file mode 100644 index 0000000000..b982badef6 --- /dev/null +++ b/flytekit/docs/source/flytekit.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/index.rst b/flytekit/docs/source/index.rst new file mode 100644 index 0000000000..d4d0730236 --- /dev/null +++ b/flytekit/docs/source/index.rst @@ -0,0 +1,94 @@ +.. DO NOT EDIT THIS FILE! + This file is the index for the old flytekit documentation. The index for the monodocs is now + at `docs_index.rst`. Please edit that file if you want to add new entries to the flytekit api + documentation. + +.. simpleble documentation master file, created by + sphinx-quickstart on Fri Mar 9 04:07:53 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +************************* +Flytekit Python Reference +************************* + +`Flytekit Tags <_tags/tagsindex.html>`__ + +This section of the documentation provides detailed descriptions of the high-level design of ``Flytekit`` and an +API reference for specific usage details of Python functions, classes, and decorators that you import to specify tasks, +build workflows, and extend ``Flytekit``. + +Installation +============ + +.. code-block:: + + pip install flytekit + +For developer environment setup instructions, see the :ref:`contributor guide `. + + +Quickstart +========== + +.. code-block:: python + + from flytekit import task, workflow + + @task + def sum(x: int, y: int) -> int: + return x + y + + @task + def square(z: int) -> int: + return z * z + + @workflow + def my_workflow(x: int, y: int) -> int: + return sum(x=square(z=x), y=square(z=y)) + + print(f"my_workflow output: {my_workflow(x=1, y=2)}") + + +Expected output: + +.. code-block:: + + my_workflow output: 5 + + +.. toctree:: + :maxdepth: 1 + :hidden: + + |plane| Getting Started + |book-reader| User Guide + |chalkboard| Tutorials + |project-diagram| Concepts + |rocket| Deployment + |book| API Reference + |hands-helping| Community + +.. NOTE: The caption text is important for the Sphinx theme to correctly render the nav header +.. https://github.com/flyteorg/furo +.. toctree:: + :maxdepth: -1 + :caption: Flytekit SDK + :hidden: + + Flytekit Python + design/index + flytekit + configuration + remote + clients + testing + extend + deck + extras.accelerators + plugins/index + tasks.extend + types.extend + experimental + pyflyte + contributing diff --git a/flytekit/docs/source/plugins/athena.rst b/flytekit/docs/source/plugins/athena.rst new file mode 100644 index 0000000000..30e7292258 --- /dev/null +++ b/flytekit/docs/source/plugins/athena.rst @@ -0,0 +1,12 @@ +.. _athena: + +################################################### +AWS Athena Plugin API reference +################################################### + +.. tags:: Integration, AWS, Data + +.. automodule:: flytekitplugins.athena + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/awsbatch.rst b/flytekit/docs/source/plugins/awsbatch.rst new file mode 100644 index 0000000000..0882ef143c --- /dev/null +++ b/flytekit/docs/source/plugins/awsbatch.rst @@ -0,0 +1,11 @@ +.. _awsbatch: + +################################################### +AWS Batch Plugin API reference +################################################### + +.. tags:: Data, Integration, AWS + +.. automodule:: flytekitplugins.awsbatch + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/awssagemaker.rst b/flytekit/docs/source/plugins/awssagemaker.rst new file mode 100644 index 0000000000..b8ded38bee --- /dev/null +++ b/flytekit/docs/source/plugins/awssagemaker.rst @@ -0,0 +1,17 @@ +:orphan: + +.. TODO: Will need to add this document back to the plugins/index.rst file +.. when sagemaker agent work is done: https://github.com/flyteorg/flyte/issues/4079 + +.. _awssagemaker: + +################################################### +AWS Sagemaker API reference +################################################### + +.. tags:: Integration, MachineLearning, AWS + +.. automodule:: flytekitplugins.awssagemaker + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/bigquery.rst b/flytekit/docs/source/plugins/bigquery.rst new file mode 100644 index 0000000000..fd7c8d5fcd --- /dev/null +++ b/flytekit/docs/source/plugins/bigquery.rst @@ -0,0 +1,12 @@ +.. _bigquery: + +################################################### +Google Bigquery Plugin API reference +################################################### + +.. tags:: GCP, Data, Integration + +.. automodule:: flytekitplugins.bigquery + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/dask.rst b/flytekit/docs/source/plugins/dask.rst new file mode 100644 index 0000000000..53e9f11fcb --- /dev/null +++ b/flytekit/docs/source/plugins/dask.rst @@ -0,0 +1,12 @@ +.. _dask: + +################################################### +Dask API reference +################################################### + +.. tags:: Integration, DistributedComputing, KubernetesOperator + +.. automodule:: flytekitplugins.dask + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/dbt.rst b/flytekit/docs/source/plugins/dbt.rst new file mode 100644 index 0000000000..0588eda641 --- /dev/null +++ b/flytekit/docs/source/plugins/dbt.rst @@ -0,0 +1,12 @@ +.. _dbt: + +################################################### +DBT Plugin API reference +################################################### + +.. tags:: Data + +.. automodule:: flytekitplugins.dbt + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/deck.rst b/flytekit/docs/source/plugins/deck.rst new file mode 100644 index 0000000000..58922c7e23 --- /dev/null +++ b/flytekit/docs/source/plugins/deck.rst @@ -0,0 +1,12 @@ +.. _deck: + +################################################### +Deck standard Plugin API reference +################################################### + +.. tags:: UI + +.. automodule:: flytekitplugins.deck + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/dolt.rst b/flytekit/docs/source/plugins/dolt.rst new file mode 100644 index 0000000000..2f937b9dbb --- /dev/null +++ b/flytekit/docs/source/plugins/dolt.rst @@ -0,0 +1,12 @@ +.. _dolt: + +################################################### +Dolt standard Plugin API reference +################################################### + +.. tags:: Integration, Data, SQL + +.. automodule:: flytekitplugins.dolt + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/duckdb.rst b/flytekit/docs/source/plugins/duckdb.rst new file mode 100644 index 0000000000..bde0783e3a --- /dev/null +++ b/flytekit/docs/source/plugins/duckdb.rst @@ -0,0 +1,12 @@ +.. _duckdb: + +################################################### +DuckDB API reference +################################################### + +.. tags:: Integration, Data, Analytics + +.. automodule:: flytekitplugins.duckdb + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/fsspec.rst b/flytekit/docs/source/plugins/fsspec.rst new file mode 100644 index 0000000000..cd2dfe88c1 --- /dev/null +++ b/flytekit/docs/source/plugins/fsspec.rst @@ -0,0 +1,14 @@ +:orphan: + +.. _fsspec: + +################################################### +FS Spec API reference +################################################### + +.. tags:: Data, AWS, GCP + +.. automodule:: flytekitplugins.fsspec + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/greatexpectations.rst b/flytekit/docs/source/plugins/greatexpectations.rst new file mode 100644 index 0000000000..d993c9e129 --- /dev/null +++ b/flytekit/docs/source/plugins/greatexpectations.rst @@ -0,0 +1,12 @@ +.. _greatexpectations: + +################################################### +Great expectations API reference +################################################### + +.. tags:: Integration, Data, SQL + +.. automodule:: flytekitplugins.great_expectations + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/hive.rst b/flytekit/docs/source/plugins/hive.rst new file mode 100644 index 0000000000..63e0abdd7c --- /dev/null +++ b/flytekit/docs/source/plugins/hive.rst @@ -0,0 +1,12 @@ +.. _hive: + +################################################### +Hive API reference +################################################### + +.. tags:: Integration, Data + +.. automodule:: flytekitplugins.hive + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/index.rst b/flytekit/docs/source/plugins/index.rst new file mode 100644 index 0000000000..06e4d7cd58 --- /dev/null +++ b/flytekit/docs/source/plugins/index.rst @@ -0,0 +1,63 @@ +.. _plugins: + +=========================== +Plugin API reference +=========================== + +* :ref:`Athena ` - AWS Athena plugin reference +* :ref:`AWS Batch ` - AWS Batch plugin reference +* :ref:`Google Bigquery ` - Google Bigquery plugin reference +* :ref:`Dask ` - Dask standard API reference +* :ref:`Deck standard ` - Deck standard API reference +* :ref:`Dolt standard ` - Dolt standard API reference +* :ref:`Great expectations ` - Great expectations API reference +* :ref:`Hive ` - Hive API reference +* :ref:`Pod ` - Pod API reference +* :ref:`KF MPI ` - KF MPI API reference +* :ref:`KF Pytorch ` - KF Pytorch API reference +* :ref:`Kf Tensorflow ` - KF Tensorflow API reference +* :ref:`Modin ` - Modin API reference +* :ref:`Pandera ` - Pandera API reference +* :ref:`Papermill ` - Papermill API reference +* :ref:`Snowflake ` - Snowflake API reference +* :ref:`Spark ` - Spark API reference +* :ref:`SQLAlchemy ` - SQLAlchemy API reference +* :ref:`ONNX PyTorch ` - ONNX PyTorch API reference +* :ref:`ONNX TensorFlow ` - ONNX TensorFlow API reference +* :ref:`ONNX ScikitLearn ` - ONNX ScikitLearn API reference +* :ref:`Ray ` - Ray API reference +* :ref:`DBT ` - DBT API reference +* :ref:`Vaex ` - Vaex API reference +* :ref:`MLflow ` - MLflow API reference +* :ref:`DuckDB ` - DuckDB API reference + +.. toctree:: + :maxdepth: 2 + :caption: API reference + + AWS Athena + AWS Batch + Google Bigquery + Dask + Deck standard + Dolt standard + Great expectations + Hive + Pod + KF MPI + KF Pytorch + KF Tensorflow + Modin + Pandera + Papermill + Snowflake + Spark + SQLAlchemy + ONNX PyTorch + ONNX TensorFlow + ONNX ScikitLearn + Ray + DBT + Vaex + MLflow + DuckDB diff --git a/flytekit/docs/source/plugins/kfmpi.rst b/flytekit/docs/source/plugins/kfmpi.rst new file mode 100644 index 0000000000..1ed280b3c7 --- /dev/null +++ b/flytekit/docs/source/plugins/kfmpi.rst @@ -0,0 +1,12 @@ +.. _kfmpi: + +################################################### +KfMPI API reference +################################################### + +.. tags:: Integration, DistributedComputing, MachineLearning, KubernetesOperator + +.. automodule:: flytekitplugins.kfmpi + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/kfpytorch.rst b/flytekit/docs/source/plugins/kfpytorch.rst new file mode 100644 index 0000000000..343d726a8f --- /dev/null +++ b/flytekit/docs/source/plugins/kfpytorch.rst @@ -0,0 +1,12 @@ +.. _kfpytorch: + +################################################### +KF Pytorch API reference +################################################### + +.. tags:: Integration, DistributedComputing, MachineLearning, KubernetesOperator + +.. automodule:: flytekitplugins.kfpytorch + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/kftensorflow.rst b/flytekit/docs/source/plugins/kftensorflow.rst new file mode 100644 index 0000000000..1a49ed7a05 --- /dev/null +++ b/flytekit/docs/source/plugins/kftensorflow.rst @@ -0,0 +1,12 @@ +.. _kftensorflow: + +################################################### +KFTensorflow Plugin API reference +################################################### + +.. tags:: Integration, DistributedComputing, MachineLearning, KubernetesOperator + +.. automodule:: flytekitplugins.kftensorflow + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/mlflow.rst b/flytekit/docs/source/plugins/mlflow.rst new file mode 100644 index 0000000000..60d1a7c66b --- /dev/null +++ b/flytekit/docs/source/plugins/mlflow.rst @@ -0,0 +1,9 @@ +.. _mlflow: + +################################################### +MLflow API reference +################################################### + +.. tags:: Integration, MachineLearning, Tracking + +.. automodule:: flytekitplugins.mlflow diff --git a/flytekit/docs/source/plugins/modin.rst b/flytekit/docs/source/plugins/modin.rst new file mode 100644 index 0000000000..735e23460a --- /dev/null +++ b/flytekit/docs/source/plugins/modin.rst @@ -0,0 +1,12 @@ +.. _modin: + +################################################### +Modin API reference +################################################### + +.. tags:: Integration, DataFrame + +.. automodule:: flytekitplugins.modin + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/onnxpytorch.rst b/flytekit/docs/source/plugins/onnxpytorch.rst new file mode 100644 index 0000000000..f66be6c08e --- /dev/null +++ b/flytekit/docs/source/plugins/onnxpytorch.rst @@ -0,0 +1,12 @@ +.. _onnxpytorch: + +########################## +PyTorch ONNX API reference +########################## + +.. tags:: Integration, MachineLearning + +.. automodule:: flytekitplugins.onnxpytorch + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/onnxscikitlearn.rst b/flytekit/docs/source/plugins/onnxscikitlearn.rst new file mode 100644 index 0000000000..cd6e9f2492 --- /dev/null +++ b/flytekit/docs/source/plugins/onnxscikitlearn.rst @@ -0,0 +1,12 @@ +.. _onnxscikitlearn: + +############################## +ScikitLearn ONNX API reference +############################## + +.. tags:: Integration, MachineLearning + +.. automodule:: flytekitplugins.onnxscikitlearn + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/onnxtensorflow.rst b/flytekit/docs/source/plugins/onnxtensorflow.rst new file mode 100644 index 0000000000..42496526d9 --- /dev/null +++ b/flytekit/docs/source/plugins/onnxtensorflow.rst @@ -0,0 +1,12 @@ +.. _onnxtensorflow: + +############################# +TensorFlow ONNX API reference +############################# + +.. tags:: Integration, MachineLearning + +.. automodule:: flytekitplugins.onnxtensorflow + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/pandera.rst b/flytekit/docs/source/plugins/pandera.rst new file mode 100644 index 0000000000..fc4b6b2f25 --- /dev/null +++ b/flytekit/docs/source/plugins/pandera.rst @@ -0,0 +1,12 @@ +.. _pandera: + +################################################### +Pandera API reference +################################################### + +.. tags:: Integration, DataFrame + +.. automodule:: flytekitplugins.pandera + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/papermill.rst b/flytekit/docs/source/plugins/papermill.rst new file mode 100644 index 0000000000..b61c5e6c90 --- /dev/null +++ b/flytekit/docs/source/plugins/papermill.rst @@ -0,0 +1,12 @@ +.. _papermill: + +################################################### +Papermill API reference +################################################### + +.. tags:: Integration, Jupyter + +.. automodule:: flytekitplugins.papermill + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/pod.rst b/flytekit/docs/source/plugins/pod.rst new file mode 100644 index 0000000000..29ba0b9c0c --- /dev/null +++ b/flytekit/docs/source/plugins/pod.rst @@ -0,0 +1,12 @@ +.. _pod: + +################################################### +Pod API reference +################################################### + +.. tags:: Integration, Kubernetes, KubernetesOperator + +.. automodule:: flytekitplugins.pod + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/ray.rst b/flytekit/docs/source/plugins/ray.rst new file mode 100644 index 0000000000..cb96ab7adc --- /dev/null +++ b/flytekit/docs/source/plugins/ray.rst @@ -0,0 +1,12 @@ +.. _ray: + +################################################### +Ray API reference +################################################### + +.. tags:: Integration, DistributedComputing, KubernetesOperator + +.. automodule:: flytekitplugins.ray + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/snowflake.rst b/flytekit/docs/source/plugins/snowflake.rst new file mode 100644 index 0000000000..0b2ba27ab3 --- /dev/null +++ b/flytekit/docs/source/plugins/snowflake.rst @@ -0,0 +1,12 @@ +.. _snowflake: + +################################################### +Snowflake API reference +################################################### + +.. tags:: Integration, Data + +.. automodule:: flytekitplugins.snowflake + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/spark.rst b/flytekit/docs/source/plugins/spark.rst new file mode 100644 index 0000000000..9dc828c177 --- /dev/null +++ b/flytekit/docs/source/plugins/spark.rst @@ -0,0 +1,12 @@ +.. _spark: + +################################################### +Spark API reference +################################################### + +.. tags:: Spark, Integration, DistributedComputing + +.. automodule:: flytekitplugins.spark + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/sqlalchemy.rst b/flytekit/docs/source/plugins/sqlalchemy.rst new file mode 100644 index 0000000000..554b38e5d2 --- /dev/null +++ b/flytekit/docs/source/plugins/sqlalchemy.rst @@ -0,0 +1,12 @@ +.. _sqlalchemy: + +################################################### +Sqlalchemy Plugin API reference +################################################### + +.. tags:: Integration, Data, SQL + +.. automodule:: flytekitplugins.sqlalchemy + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/plugins/vaex.rst b/flytekit/docs/source/plugins/vaex.rst new file mode 100644 index 0000000000..efb72076bc --- /dev/null +++ b/flytekit/docs/source/plugins/vaex.rst @@ -0,0 +1,10 @@ +.. _vaex: + +################################################### +Vaex API reference +################################################### + +.. automodule:: flytekitplugins.vaex + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/pyflyte.rst b/flytekit/docs/source/pyflyte.rst new file mode 100644 index 0000000000..707977c316 --- /dev/null +++ b/flytekit/docs/source/pyflyte.rst @@ -0,0 +1,7 @@ +########### +Pyflyte CLI +########### + +.. click:: flytekit.clis.sdk_in_container.pyflyte:main + :prog: pyflyte + :nested: full diff --git a/flytekit/docs/source/remote.rst b/flytekit/docs/source/remote.rst new file mode 100644 index 0000000000..69ba87b7d9 --- /dev/null +++ b/flytekit/docs/source/remote.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.remote + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/tasks.extend.rst b/flytekit/docs/source/tasks.extend.rst new file mode 100644 index 0000000000..b012b2f1b1 --- /dev/null +++ b/flytekit/docs/source/tasks.extend.rst @@ -0,0 +1,25 @@ +############ +Custom Tasks +############ + +.. tags:: Extensibility, Intermediate + +Flytekit ships with an extensible task system, which makes it easy for anyone to extend and add new task types. + +Refer to the :ref:`cookbook:prebuilt_container` and :ref:`cookbook:user_container` guides if you'd like to contribute a new task type. + +.. automodule:: flytekit.core.base_task + :no-members: + :no-inherited-members: + :no-special-members: + +.. automodule:: flytekit.core.python_function_task + :no-members: + :no-inherited-members: + :no-special-members: + +.. toctree:: + :maxdepth: 1 + + extras.tasks + extras.sqlite3 diff --git a/flytekit/docs/source/testing.rst b/flytekit/docs/source/testing.rst new file mode 100644 index 0000000000..41c7da899b --- /dev/null +++ b/flytekit/docs/source/testing.rst @@ -0,0 +1,5 @@ + +.. automodule:: flytekit.testing + :members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/types.builtins.directory.rst b/flytekit/docs/source/types.builtins.directory.rst new file mode 100644 index 0000000000..b851800ea3 --- /dev/null +++ b/flytekit/docs/source/types.builtins.directory.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.types.directory + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/types.builtins.file.rst b/flytekit/docs/source/types.builtins.file.rst new file mode 100644 index 0000000000..929fa99527 --- /dev/null +++ b/flytekit/docs/source/types.builtins.file.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.types.file + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/types.builtins.structured.rst b/flytekit/docs/source/types.builtins.structured.rst new file mode 100644 index 0000000000..e31a8d4db5 --- /dev/null +++ b/flytekit/docs/source/types.builtins.structured.rst @@ -0,0 +1,4 @@ +.. automodule:: flytekit.types.structured + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/flytekit/docs/source/types.extend.rst b/flytekit/docs/source/types.extend.rst new file mode 100644 index 0000000000..db1cb8dfff --- /dev/null +++ b/flytekit/docs/source/types.extend.rst @@ -0,0 +1,19 @@ +############ +Custom Types +############ + +.. tags:: Extensibility, Intermediate + +Flytekit ships with an extensible type system, which makes it easy for anyone to extend and add new types. + +Refer to the :ref:`extensibility contribution guide ` if you'd like to contribute a Flyte type. + +.. toctree:: + :maxdepth: 1 + + types.builtins.structured + types.builtins.file + types.builtins.directory + extras.pytorch + extras.tensorflow + extras.sklearn diff --git a/flytekit/flytekit/__init__.py b/flytekit/flytekit/__init__.py new file mode 100644 index 0000000000..bba4947b38 --- /dev/null +++ b/flytekit/flytekit/__init__.py @@ -0,0 +1,309 @@ +""" +===================== +Core Flytekit +===================== + +.. currentmodule:: flytekit + +This package contains all of the most common abstractions you'll need to write Flyte workflows and extend Flytekit. + +Basic Authoring +=============== + +These are the essentials needed to get started writing tasks and workflows. + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + task + workflow + kwtypes + current_context + ExecutionParameters + FlyteContext + map_task + ~core.workflow.ImperativeWorkflow + ~core.node_creation.create_node + ~core.promise.NodeOutput + FlyteContextManager + +.. important:: + + Tasks and Workflows can both be locally run, assuming the relevant tasks are capable of local execution. + This is useful for unit testing. + + +Branching and Conditionals +========================== + +Branches and conditionals can be expressed explicitly in Flyte. These conditions are evaluated +in the flyte engine and hence should be used for control flow. ``dynamic workflows`` can be used to perform custom conditional logic not supported by flytekit + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + conditional + + +Customizing Tasks & Workflows +============================== + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + TaskMetadata - Wrapper object that allows users to specify Task + Resources - Things like CPUs/Memory, etc. + WorkflowFailurePolicy - Customizes what happens when a workflow fails. + PodTemplate - Custom PodTemplate for a task. + +Dynamic and Nested Workflows +============================== +See the :py:mod:`Dynamic ` module for more information. + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + dynamic + +Signaling +========= + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + approve + sleep + wait_for_input + +Scheduling +============================ + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + CronSchedule + FixedRate + +Notifications +============================ + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + Email + PagerDuty + Slack + +Reference Entities +==================== + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + get_reference_entity + LaunchPlanReference + TaskReference + WorkflowReference + reference_task + reference_workflow + reference_launch_plan + +Core Task Types +================= + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + SQLTask + ContainerTask + PythonFunctionTask + PythonInstanceTask + LaunchPlan + +Secrets and SecurityContext +============================ + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + Secret + SecurityContext + + +Common Flyte IDL Objects +========================= + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + AuthRole + Labels + Annotations + WorkflowExecutionPhase + Blob + BlobMetadata + Literal + Scalar + LiteralType + BlobType + +Task Utilities +============== + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + HashMethod + +Documentation +============= + +.. autosummary:: + :nosignatures: + :template: custom.rst + :toctree: generated/ + + Description + Documentation + SourceCode + +""" +import os +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: + from importlib.metadata import entry_points + +from flytekit._version import __version__ +from flytekit.core.base_sql_task import SQLTask +from flytekit.core.base_task import SecurityContext, TaskMetadata, kwtypes +from flytekit.core.checkpointer import Checkpoint +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.dynamic_workflow_task import dynamic +from flytekit.core.gate import approve, sleep, wait_for_input +from flytekit.core.hash import HashMethod +from flytekit.core.launch_plan import LaunchPlan, reference_launch_plan +from flytekit.core.map_task import map_task +from flytekit.core.notification import Email, PagerDuty, Slack +from flytekit.core.pod_template import PodTemplate +from flytekit.core.python_function_task import PythonFunctionTask, PythonInstanceTask +from flytekit.core.reference import get_reference_entity +from flytekit.core.reference_entity import LaunchPlanReference, TaskReference, WorkflowReference +from flytekit.core.resources import Resources +from flytekit.core.schedule import CronSchedule, FixedRate +from flytekit.core.task import Secret, reference_task, task +from flytekit.core.type_engine import BatchSize +from flytekit.core.workflow import ImperativeWorkflow as Workflow +from flytekit.core.workflow import WorkflowFailurePolicy, reference_workflow, workflow +from flytekit.deck import Deck +from flytekit.image_spec import ImageSpec +from flytekit.loggers import LOGGING_RICH_FMT_ENV_VAR, logger +from flytekit.models.common import Annotations, AuthRole, Labels +from flytekit.models.core.execution import WorkflowExecutionPhase +from flytekit.models.core.types import BlobType +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.sensor.sensor_engine import SensorEngine +from flytekit.types import directory, file, iterator +from flytekit.types.structured.structured_dataset import ( + StructuredDataset, + StructuredDatasetFormat, + StructuredDatasetTransformerEngine, + StructuredDatasetType, +) + + +def current_context() -> ExecutionParameters: + """ + Use this method to get a handle of specific parameters available in a flyte task. + + Usage + + .. code-block:: python + + flytekit.current_context().logging.info(...) + + Available params are documented in :py:class:`flytekit.core.context_manager.ExecutionParams`. + There are some special params, that should be available + """ + return FlyteContextManager.current_context().execution_state.user_space_params + + +def new_context() -> Generator[FlyteContext, None, None]: + return FlyteContextManager.with_context(FlyteContextManager.current_context().new_builder()) + + +def load_implicit_plugins(): + """ + This method allows loading all plugins that have the entrypoint specification. This uses the plugin loading + behavior as explained `here <>`_. + + This is an opt in system and plugins that have an implicit loading requirement should add the implicit loading + entrypoint specification to their setup.py. The following example shows how we can autoload a module called fsspec + (whose init files contains the necessary plugin registration step) + + .. code-block:: + + # note the group is always ``flytekit.plugins`` + setup( + ... + entry_points={'flytekit.plugins': 'fsspec=flytekitplugins.fsspec'}, + ... + ) + + This works as long as the fsspec module has + + .. code-block:: + + # For data persistence plugins + DataPersistencePlugins.register_plugin(f"{k}://", FSSpecPersistence, force=True) + # OR for type plugins + TypeEngine.register(PanderaTransformer()) + # etc + + """ + discovered_plugins = entry_points(group="flytekit.plugins") + for p in discovered_plugins: + p.load() + + +# Load all implicit plugins +load_implicit_plugins() + +# Pretty-print exception messages +if os.environ.get(LOGGING_RICH_FMT_ENV_VAR) != "0": + traceback.install(width=None, extra_lines=0) diff --git a/flytekit/flytekit/clients/__init__.py b/flytekit/flytekit/clients/__init__.py new file mode 100644 index 0000000000..1b08e1c567 --- /dev/null +++ b/flytekit/flytekit/clients/__init__.py @@ -0,0 +1,19 @@ +""" +===================== +Clients +===================== + +.. currentmodule:: flytekit.clients + +This module provides lower level access to a Flyte backend. + +.. _clients_module: + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~friendly.SynchronousFlyteClient + ~raw.RawSynchronousFlyteClient +""" diff --git a/flytekit/flytekit/clients/auth/__init__.py b/flytekit/flytekit/clients/auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/clients/auth/auth_client.py b/flytekit/flytekit/clients/auth/auth_client.py new file mode 100644 index 0000000000..4735a446be --- /dev/null +++ b/flytekit/flytekit/clients/auth/auth_client.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import base64 as _base64 +import hashlib as _hashlib +import http.server as _BaseHTTPServer +import logging +import multiprocessing +import os as _os +import re as _re +import typing +import urllib.parse as _urlparse +import webbrowser as _webbrowser +from dataclasses import dataclass +from http import HTTPStatus as _StatusCodes +from multiprocessing import get_context +from urllib.parse import urlencode as _urlencode + +import click +import requests as _requests + +from .default_html import get_default_success_html +from .exceptions import AccessTokenNotFoundError +from .keyring import Credentials + +_code_verifier_length = 64 +_random_seed_length = 40 +_utf_8 = "utf-8" + + +def _generate_code_verifier(): + """ + Generates a 'code_verifier' as described in https://tools.ietf.org/html/rfc7636#section-4.1 + Adapted from https://github.com/openstack/deb-python-oauth2client/blob/master/oauth2client/_pkce.py. + :return str: + """ + code_verifier = _base64.urlsafe_b64encode(_os.urandom(_code_verifier_length)).decode(_utf_8) + # Eliminate invalid characters. + code_verifier = _re.sub(r"[^a-zA-Z0-9_\-.~]+", "", code_verifier) + if len(code_verifier) < 43: + raise ValueError("Verifier too short. number of bytes must be > 30.") + elif len(code_verifier) > 128: + raise ValueError("Verifier too long. number of bytes must be < 97.") + return code_verifier + + +def _generate_state_parameter(): + state = _base64.urlsafe_b64encode(_os.urandom(_random_seed_length)).decode(_utf_8) + # Eliminate invalid characters. + code_verifier = _re.sub("[^a-zA-Z0-9-_.,]+", "", state) + return code_verifier + + +def _create_code_challenge(code_verifier): + """ + Adapted from https://github.com/openstack/deb-python-oauth2client/blob/master/oauth2client/_pkce.py. + :param str code_verifier: represents a code verifier generated by generate_code_verifier() + :return str: urlsafe base64-encoded sha256 hash digest + """ + code_challenge = _hashlib.sha256(code_verifier.encode(_utf_8)).digest() + code_challenge = _base64.urlsafe_b64encode(code_challenge).decode(_utf_8) + # Eliminate invalid characters + code_challenge = code_challenge.replace("=", "") + return code_challenge + + +class AuthorizationCode(object): + def __init__(self, code, state): + self._code = code + self._state = state + + @property + def code(self): + return self._code + + @property + def state(self): + return self._state + + +@dataclass +class EndpointMetadata(object): + """ + This class can be used to control the rendering of the page on login successful or failure + """ + + endpoint: str + success_html: typing.Optional[bytes] = None + failure_html: typing.Optional[bytes] = None + + +class OAuthCallbackHandler(_BaseHTTPServer.BaseHTTPRequestHandler): + """ + A simple wrapper around BaseHTTPServer.BaseHTTPRequestHandler that handles a callback URL that accepts an + authorization token. + """ + + def do_GET(self): + url = _urlparse.urlparse(self.path) + if url.path.strip("/") == self.server.redirect_path.strip("/"): + self.send_response(_StatusCodes.OK) + self.send_header("Content-type", "text/html") + self.end_headers() + self.handle_login(dict(_urlparse.parse_qsl(url.query))) + if self.server.remote_metadata.success_html is None: + self.wfile.write(bytes(get_default_success_html(self.server.remote_metadata.endpoint), "utf-8")) + self.wfile.flush() + else: + self.send_response(_StatusCodes.NOT_FOUND) + + def handle_login(self, data: dict): + self.server.handle_authorization_code(AuthorizationCode(data["code"], data["state"])) + + +class OAuthHTTPServer(_BaseHTTPServer.HTTPServer): + """ + A simple wrapper around the BaseHTTPServer.HTTPServer implementation that binds an authorization_client for handling + authorization code callbacks. + """ + + def __init__( + self, + server_address: typing.Tuple[str, int], + remote_metadata: EndpointMetadata, + request_handler_class: typing.Type[_BaseHTTPServer.BaseHTTPRequestHandler], + bind_and_activate: bool = True, + redirect_path: str = None, + queue: multiprocessing.Queue = None, + ): + _BaseHTTPServer.HTTPServer.__init__(self, server_address, request_handler_class, bind_and_activate) + self._redirect_path = redirect_path + self._remote_metadata = remote_metadata + self._auth_code = None + self._queue = queue + + @property + def redirect_path(self) -> str: + return self._redirect_path + + @property + def remote_metadata(self) -> EndpointMetadata: + return self._remote_metadata + + def handle_authorization_code(self, auth_code: str): + self._queue.put(auth_code) + self.server_close() + + def handle_request(self, queue: multiprocessing.Queue = None) -> typing.Any: + self._queue = queue + return super().handle_request() + + +class _SingletonPerEndpoint(type): + """ + A metaclass to create per endpoint singletons for AuthorizationClient objects + """ + + _instances: typing.Dict[str, AuthorizationClient] = {} + + def __call__(cls, *args, **kwargs): + endpoint = "" + if args: + endpoint = args[0] + elif "auth_endpoint" in kwargs: + endpoint = kwargs["auth_endpoint"] + else: + raise ValueError("parameter auth_endpoint is required") + if endpoint not in cls._instances: + cls._instances[endpoint] = super(_SingletonPerEndpoint, cls).__call__(*args, **kwargs) + return cls._instances[endpoint] + + +class AuthorizationClient(metaclass=_SingletonPerEndpoint): + """ + Authorization client that stores the credentials in keyring and uses oauth2 standard flow to retrieve the + credentials. NOTE: This will open an web browser to retrieve the credentials. + """ + + def __init__( + self, + endpoint: str, + auth_endpoint: str, + token_endpoint: str, + audience: typing.Optional[str] = None, + scopes: typing.Optional[typing.List[str]] = None, + client_id: typing.Optional[str] = None, + redirect_uri: typing.Optional[str] = None, + endpoint_metadata: typing.Optional[EndpointMetadata] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + session: typing.Optional[_requests.Session] = None, + request_auth_code_params: typing.Optional[typing.Dict[str, str]] = None, + request_access_token_params: typing.Optional[typing.Dict[str, str]] = None, + refresh_access_token_params: typing.Optional[typing.Dict[str, str]] = None, + add_request_auth_code_params_to_request_access_token_params: typing.Optional[bool] = False, + ): + """ + Create new AuthorizationClient + + :param endpoint: str endpoint to connect to + :param auth_endpoint: str endpoint where auth metadata can be found + :param token_endpoint: str endpoint to retrieve token from + :param audience: (optional) Audience parameter for Auth0 + :param scopes: list[str] oauth2 scopes + :param client_id: oauth2 client id + :param redirect_uri: oauth2 redirect uri + :param endpoint_metadata: EndpointMetadata object to control the rendering of the page on login successful or failure + :param verify: (optional) Either a boolean, in which case it controls whether we verify + the server's TLS certificate, or a string, in which case it must be a path + to a CA bundle to use. Defaults to ``True``. When set to + ``False``, requests will accept any TLS certificate presented by + the server, and will ignore hostname mismatches and/or expired + certificates, which will make your application vulnerable to + man-in-the-middle (MitM) attacks. Setting verify to ``False`` + may be useful during local development or testing. + :param session: (optional) A custom requests.Session object to use for making HTTP requests. + If not provided, a new Session object will be created. + :param request_auth_code_params: (optional) dict of parameters to add to login uri opened in the browser + :param request_access_token_params: (optional) dict of parameters to add when exchanging the auth code for the access token + :param refresh_access_token_params: (optional) dict of parameters to add when refreshing the access token + :param add_request_auth_code_params_to_request_access_token_params: Whether to add the `request_auth_code_params` to + the parameters sent when exchanging the auth code for the access token. Defaults to False. + Required e.g. for the PKCE flow with flyteadmin. + Not required for e.g. the standard OAuth2 flow on GCP. + """ + self._endpoint = endpoint + self._auth_endpoint = auth_endpoint + if endpoint_metadata is None: + remote_url = _urlparse.urlparse(self._auth_endpoint) + self._remote = EndpointMetadata(endpoint=remote_url.hostname) + else: + self._remote = endpoint_metadata + self._token_endpoint = token_endpoint + self._client_id = client_id + self._audience = audience + self._scopes = scopes or [] + self._redirect_uri = redirect_uri + state = _generate_state_parameter() + self._state = state + self._verify = verify + self._headers = {"content-type": "application/x-www-form-urlencoded"} + self._session = session or _requests.Session() + + self._request_auth_code_params = { + "client_id": client_id, # This must match the Client ID of the OAuth application. + "response_type": "code", # Indicates the authorization code grant + "scope": " ".join(s.strip("' ") for s in self._scopes).strip( + "[]'" + ), # ensures that the /token endpoint returns an ID and refresh token + # callback location where the user-agent will be directed to. + "redirect_uri": self._redirect_uri, + "state": state, + } + + # Conditionally add audience param if provided - value is not None + if self._audience: + self._request_auth_code_params["audience"] = self._audience + + if request_auth_code_params: + # Allow adding additional parameters to the request_auth_code_params + self._request_auth_code_params.update(request_auth_code_params) + + self._request_access_token_params = request_access_token_params or {} + self._refresh_access_token_params = refresh_access_token_params or {} + + if add_request_auth_code_params_to_request_access_token_params: + self._request_access_token_params.update(self._request_auth_code_params) + + def __repr__(self): + return f"AuthorizationClient({self._auth_endpoint}, {self._token_endpoint}, {self._client_id}, {self._scopes}, {self._redirect_uri})" + + def _create_callback_server(self): + server_url = _urlparse.urlparse(self._redirect_uri) + server_address = (server_url.hostname, server_url.port) + return OAuthHTTPServer( + server_address, + self._remote, + OAuthCallbackHandler, + redirect_path=server_url.path, + ) + + def _request_authorization_code(self): + scheme, netloc, path, _, _, _ = _urlparse.urlparse(self._auth_endpoint) + query = _urlencode(self._request_auth_code_params) + endpoint = _urlparse.urlunparse((scheme, netloc, path, None, query, None)) + logging.debug(f"Requesting authorization code through {endpoint}") + + success = _webbrowser.open_new_tab(endpoint) + if not success: + click.secho(f"Please open the following link in your browser to authenticate: {endpoint}") + + def _credentials_from_response(self, auth_token_resp) -> Credentials: + """ + The auth_token_resp body is of the form: + { + "access_token": "foo", + "refresh_token": "bar", + "token_type": "Bearer" + } + + Can additionally contain "expires_in" and "id_token" fields. + """ + response_body = auth_token_resp.json() + refresh_token = None + id_token = None + if "access_token" not in response_body: + raise ValueError('Expected "access_token" in response from oauth server') + if "refresh_token" in response_body: + refresh_token = response_body["refresh_token"] + if "expires_in" in response_body: + expires_in = response_body["expires_in"] + access_token = response_body["access_token"] + if "id_token" in response_body: + id_token = response_body["id_token"] + + return Credentials(access_token, refresh_token, self._endpoint, expires_in=expires_in, id_token=id_token) + + def _request_access_token(self, auth_code) -> Credentials: + if self._state != auth_code.state: + raise ValueError(f"Unexpected state parameter [{auth_code.state}] passed") + + params = { + "code": auth_code.code, + "grant_type": "authorization_code", + } + + params.update(self._request_access_token_params) + + resp = self._session.post( + url=self._token_endpoint, + data=params, + headers=self._headers, + allow_redirects=False, + verify=self._verify, + ) + if resp.status_code != _StatusCodes.OK: + # TODO: handle expected (?) error cases: + # https://auth0.com/docs/flows/guides/device-auth/call-api-device-auth#token-responses + raise Exception( + "Failed to request access token with response: [{}] {}".format(resp.status_code, resp.content) + ) + return self._credentials_from_response(resp) + + def get_creds_from_remote(self) -> Credentials: + """ + This is the entrypoint method. It will kickoff the full authentication flow and trigger a web-browser to + retrieve credentials + """ + # In the absence of globally-set token values, initiate the token request flow + ctx = get_context("fork") + q = ctx.Queue() + + # First prepare the callback server in the background + server = self._create_callback_server() + + server_process = ctx.Process(target=server.handle_request, args=(q,)) + server_process.daemon = True + + try: + server_process.start() + + # Send the call to request the authorization code in the background + self._request_authorization_code() + + # Request the access token once the auth code has been received. + auth_code = q.get() + return self._request_access_token(auth_code) + finally: + server_process.terminate() + + def refresh_access_token(self, credentials: Credentials) -> Credentials: + if credentials.refresh_token is None: + raise AccessTokenNotFoundError("no refresh token available with which to refresh authorization credentials") + + data = { + "refresh_token": credentials.refresh_token, + "grant_type": "refresh_token", + "client_id": self._client_id, + } + + data.update(self._refresh_access_token_params) + + resp = self._session.post( + url=self._token_endpoint, + data=data, + headers=self._headers, + allow_redirects=False, + verify=self._verify, + ) + if resp.status_code != _StatusCodes.OK: + # In the absence of a successful response, assume the refresh token is expired. This should indicate + # to the caller that the AuthorizationClient is defunct and a new one needs to be re-initialized. + raise AccessTokenNotFoundError(f"Non-200 returned from refresh token endpoint {resp.status_code}") + + return self._credentials_from_response(resp) diff --git a/flytekit/flytekit/clients/auth/authenticator.py b/flytekit/flytekit/clients/auth/authenticator.py new file mode 100644 index 0000000000..fdf1d13eae --- /dev/null +++ b/flytekit/flytekit/clients/auth/authenticator.py @@ -0,0 +1,323 @@ +import logging +import subprocess +import typing +from abc import abstractmethod +from dataclasses import dataclass + +import click +import requests + +from . import token_client +from .auth_client import AuthorizationClient +from .exceptions import AccessTokenNotFoundError, AuthenticationError +from .keyring import Credentials, KeyringStore + + +@dataclass +class ClientConfig: + """ + Client Configuration that is needed by the authenticator + """ + + token_endpoint: str + authorization_endpoint: str + redirect_uri: str + client_id: str + device_authorization_endpoint: typing.Optional[str] = None + scopes: typing.List[str] = None + header_key: str = "authorization" + audience: typing.Optional[str] = None + + +class ClientConfigStore(object): + """ + Client Config store retrieve client config. this can be done in multiple ways + """ + + @abstractmethod + def get_client_config(self) -> ClientConfig: + ... + + +class StaticClientConfigStore(ClientConfigStore): + def __init__(self, cfg: ClientConfig): + self._cfg = cfg + + def get_client_config(self) -> ClientConfig: + return self._cfg + + +class Authenticator(object): + """ + Base authenticator for all authentication flows + """ + + def __init__( + self, + endpoint: str, + header_key: str, + credentials: Credentials = None, + http_proxy_url: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + ): + self._endpoint = endpoint + self._creds = credentials + self._header_key = header_key if header_key else "authorization" + self._http_proxy_url = http_proxy_url + self._verify = verify + + def get_credentials(self) -> Credentials: + return self._creds + + def _set_credentials(self, creds): + self._creds = creds + + def _set_header_key(self, h: str): + self._header_key = h + + def fetch_grpc_call_auth_metadata(self) -> typing.Optional[typing.Tuple[str, str]]: + if self._creds: + return self._header_key, f"Bearer {self._creds.access_token}" + return None + + @abstractmethod + def refresh_credentials(self): + ... + + +class PKCEAuthenticator(Authenticator): + """ + This Authenticator encapsulates the entire PKCE flow and automatically opens a browser window for login + + For Auth0 - you will need to manually configure your config.yaml to include a scopes list of the syntax: + admin.scopes: ["offline_access", "offline", "all", "openid"] and/or similar scopes in order to get the refresh token + + caching. Otherwise, it will just receive the access token alone. Your FlyteCTL Helm config however should only + contain ["offline", "all"] - as OIDC scopes are ungrantable in Auth0 customer APIs. They are simply requested + for in the POST request during the token caching process. + """ + + def __init__( + self, + endpoint: str, + cfg_store: ClientConfigStore, + scopes: typing.Optional[typing.List[str]] = None, + header_key: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + session: typing.Optional[requests.Session] = None, + ): + """ + Initialize with default creds from KeyStore using the endpoint name + """ + super().__init__(endpoint, header_key, KeyringStore.retrieve(endpoint), verify=verify) + self._cfg_store = cfg_store + self._auth_client = None + self._scopes = scopes + self._session = session or requests.Session() + + def _initialize_auth_client(self): + if not self._auth_client: + from .auth_client import _create_code_challenge, _generate_code_verifier + + code_verifier = _generate_code_verifier() + code_challenge = _create_code_challenge(code_verifier) + + cfg = self._cfg_store.get_client_config() + self._set_header_key(cfg.header_key) + self._auth_client = AuthorizationClient( + endpoint=self._endpoint, + redirect_uri=cfg.redirect_uri, + client_id=cfg.client_id, + # Audience only needed for Auth0 - Taken from client config + audience=cfg.audience, + scopes=self._scopes or cfg.scopes, + # self._scopes refers to flytekit.configuration.PlatformConfig (config.yaml) + # cfg.scopes refers to PublicClientConfig scopes (can be defined in Helm deployments) + auth_endpoint=cfg.authorization_endpoint, + token_endpoint=cfg.token_endpoint, + verify=self._verify, + session=self._session, + request_auth_code_params={ + "code_challenge": code_challenge, + "code_challenge_method": "S256", + }, + request_access_token_params={ + "code_verifier": code_verifier, + }, + refresh_access_token_params={}, + add_request_auth_code_params_to_request_access_token_params=True, + ) + + def refresh_credentials(self): + """ """ + self._initialize_auth_client() + if self._creds: + """We have an access token so lets try to refresh it""" + try: + self._creds = self._auth_client.refresh_access_token(self._creds) + if self._creds: + KeyringStore.store(self._creds) + return + except AccessTokenNotFoundError: + logging.warning("Failed to refresh token. Kicking off a full authorization flow.") + KeyringStore.delete(self._endpoint) + + self._creds = self._auth_client.get_creds_from_remote() + KeyringStore.store(self._creds) + + +class CommandAuthenticator(Authenticator): + """ + This Authenticator retrieves access_token using the provided command + """ + + def __init__(self, command: typing.List[str], header_key: str = None): + self._cmd = command + if not self._cmd: + raise AuthenticationError("Command cannot be empty for command authenticator") + super().__init__(None, header_key) + + def refresh_credentials(self): + """ + This function is used when the configuration value for AUTH_MODE is set to 'external_process'. + It reads an id token generated by an external process started by running the 'command'. + """ + logging.debug("Starting external process to generate id token. Command {}".format(self._cmd)) + try: + output = subprocess.run(self._cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + logging.error("Failed to generate token from command {}".format(self._cmd)) + raise AuthenticationError("Problems refreshing token with command: " + str(e)) + self._creds = Credentials(output.stdout.strip()) + + +class ClientCredentialsAuthenticator(Authenticator): + """ + This Authenticator uses ClientId and ClientSecret to authenticate + """ + + def __init__( + self, + endpoint: str, + client_id: str, + client_secret: str, + cfg_store: ClientConfigStore, + header_key: typing.Optional[str] = None, + scopes: typing.Optional[typing.List[str]] = None, + http_proxy_url: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + audience: typing.Optional[str] = None, + session: typing.Optional[requests.Session] = 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 + # Use scopes from `flytekit.configuration.PlatformConfig` if passed + self._scopes = scopes or cfg.scopes + self._client_id = client_id + self._client_secret = client_secret + self._audience = audience or cfg.audience + self._session = session or requests.Session() + super().__init__(endpoint, cfg.header_key or header_key, http_proxy_url=http_proxy_url, verify=verify) + + def refresh_credentials(self): + """ + This function is used by the _handle_rpc_error() decorator, depending on the AUTH_MODE config object. This handler + is meant for SDK use-cases of auth (like pyflyte, or when users call SDK functions that require access to Admin, + like when waiting for another workflow to complete from within a task). This function uses basic auth, which means + the credentials for basic auth must be present from wherever this code is running. + + """ + token_endpoint = self._token_endpoint + scopes = self._scopes + audience = self._audience + + # 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 = token_client.get_basic_authorization_header(self._client_id, self._client_secret) + + token, expires_in = token_client.get_token( + token_endpoint=token_endpoint, + authorization_header=authorization_header, + http_proxy_url=self._http_proxy_url, + verify=self._verify, + scopes=scopes, + audience=audience, + session=self._session, + ) + + 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, + scopes: typing.Optional[typing.List[str]] = None, + http_proxy_url: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + session: typing.Optional[requests.Session] = None, + ): + cfg = cfg_store.get_client_config() + self._audience = audience or cfg.audience + self._client_id = cfg.client_id + self._device_auth_endpoint = cfg.device_authorization_endpoint + # Input param: scopes refers to flytekit.configuration.PlatformConfig (config.yaml) + # cfg.scopes refers to PublicClientConfig scopes (can be defined in Helm deployments) + # Use "scope" from object instantiation if value is not None - otherwise, default to cfg.scopes + self._scopes = scopes or 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" + ) + self._session = session or requests.Session() + super().__init__( + endpoint=endpoint, + header_key=header_key or cfg.header_key, + credentials=KeyringStore.retrieve(endpoint), + http_proxy_url=http_proxy_url, + verify=verify, + ) + + def refresh_credentials(self): + resp = token_client.get_device_code( + self._device_auth_endpoint, + self._client_id, + self._audience, + self._scopes, + self._http_proxy_url, + self._verify, + self._session, + ) + text = f"To Authenticate, navigate in a browser to the following URL: {click.style(resp.verification_uri, fg='blue', underline=True)} and enter code: {click.style(resp.user_code, fg='blue')}" + click.secho(text) + try: + # Currently the refresh token is not retrieved. 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, + audience=self._audience, + scopes=self._scopes, + http_proxy_url=self._http_proxy_url, + verify=self._verify, + ) + 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/flytekit/clients/auth/default_html.py b/flytekit/flytekit/clients/auth/default_html.py new file mode 100644 index 0000000000..32d76b5298 --- /dev/null +++ b/flytekit/flytekit/clients/auth/default_html.py @@ -0,0 +1,22 @@ +from contextlib import suppress + + +def get_default_success_html(endpoint: str) -> str: + from flytekit.configuration.plugin import get_plugin + + with suppress(AttributeError): + success_html = get_plugin().get_auth_success_html(endpoint) + if success_html is not None: + return success_html + + return f""" + + + OAuth2 Authentication Success + + +

Successfully logged into {endpoint}

+ Flyte login + + +""" # noqa diff --git a/flytekit/flytekit/clients/auth/exceptions.py b/flytekit/flytekit/clients/auth/exceptions.py new file mode 100644 index 0000000000..5086c5b6e1 --- /dev/null +++ b/flytekit/flytekit/clients/auth/exceptions.py @@ -0,0 +1,22 @@ +class AccessTokenNotFoundError(RuntimeError): + """ + This error is raised with Access token is not found or if Refreshing the token fails + """ + + pass + + +class AuthenticationError(RuntimeError): + """ + This is raised for any AuthenticationError + """ + + pass + + +class AuthenticationPending(RuntimeError): + """ + This is raised if the token endpoint returns authentication pending + """ + + pass diff --git a/flytekit/flytekit/clients/auth/keyring.py b/flytekit/flytekit/clients/auth/keyring.py new file mode 100644 index 0000000000..a46cf7aad2 --- /dev/null +++ b/flytekit/flytekit/clients/auth/keyring.py @@ -0,0 +1,81 @@ +import logging +import typing +from dataclasses import dataclass + +import keyring as _keyring +from keyring.errors import NoKeyringError, PasswordDeleteError + + +@dataclass +class Credentials(object): + """ + Stores the credentials together + """ + + access_token: str + refresh_token: str = "na" + for_endpoint: str = "flyte-default" + expires_in: typing.Optional[int] = None + id_token: typing.Optional[str] = None + + +class KeyringStore: + """ + Methods to access Keyring Store. + """ + + _access_token_key = "access_token" + _refresh_token_key = "refresh_token" + _id_token_key = "id_token" + + @staticmethod + def store(credentials: Credentials) -> Credentials: + try: + if credentials.refresh_token: + _keyring.set_password( + credentials.for_endpoint, + KeyringStore._refresh_token_key, + credentials.refresh_token, + ) + _keyring.set_password( + credentials.for_endpoint, + KeyringStore._access_token_key, + credentials.access_token, + ) + if credentials.id_token: + _keyring.set_password( + credentials.for_endpoint, + KeyringStore._id_token_key, + credentials.id_token, + ) + except NoKeyringError as e: + logging.debug(f"KeyRing not available, tokens will not be cached. Error: {e}") + return credentials + + @staticmethod + def retrieve(for_endpoint: str) -> typing.Optional[Credentials]: + try: + refresh_token = _keyring.get_password(for_endpoint, KeyringStore._refresh_token_key) + access_token = _keyring.get_password(for_endpoint, KeyringStore._access_token_key) + id_token = _keyring.get_password(for_endpoint, KeyringStore._id_token_key) + except NoKeyringError as e: + logging.debug(f"KeyRing not available, tokens will not be cached. Error: {e}") + return None + + if not access_token and not id_token: + return None + return Credentials(access_token, refresh_token, for_endpoint, id_token=id_token) + + @staticmethod + def delete(for_endpoint: str): + def _delete_key(key): + try: + _keyring.delete_password(for_endpoint, key) + except PasswordDeleteError as e: + logging.debug(f"Key {key} not found in key store, Ignoring. Error: {e}") + except NoKeyringError as e: + logging.debug(f"KeyRing not available, Key {key} deletion failed. Error: {e}") + + _delete_key(KeyringStore._access_token_key) + _delete_key(KeyringStore._refresh_token_key) + _delete_key(KeyringStore._id_token_key) diff --git a/flytekit/flytekit/clients/auth/token_client.py b/flytekit/flytekit/clients/auth/token_client.py new file mode 100644 index 0000000000..b7e505a2ac --- /dev/null +++ b/flytekit/flytekit/clients/auth/token_client.py @@ -0,0 +1,183 @@ +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 import logger +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', + 'expires_in': 600, + 'interval': 5} + """ + + device_code: str + user_code: str + verification_uri: 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"], + 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, + audience: typing.Optional[str] = None, + grant_type: GrantType = GrantType.CLIENT_CREDS, + http_proxy_url: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + session: typing.Optional[requests.Session] = None, +) -> 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(s.strip("' ") for s in scopes).strip("[]'") + if audience: + body["audience"] = audience + + proxies = {"https": http_proxy_url, "http": http_proxy_url} if http_proxy_url else None + + if not session: + session = requests.Session() + response = session.post(token_endpoint, data=body, headers=headers, proxies=proxies, verify=verify) + + 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, + http_proxy_url: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, + session: typing.Optional[requests.Session] = None, +) -> DeviceCodeResponse: + """ + Retrieves the device Authentication code that can be done to authenticate the request using a browser on a + separate device + """ + _scope = " ".join(s.strip("' ") for s in scope).strip("[]'") if scope is not None else "" + payload = {"client_id": client_id, "scope": _scope, "audience": audience} + proxies = {"https": http_proxy_url, "http": http_proxy_url} if http_proxy_url else None + if not session: + session = requests.Session() + resp = session.post(device_auth_endpoint, payload, proxies=proxies, verify=verify) + 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, + audience: typing.Optional[str] = None, + scopes: typing.Optional[str] = None, + http_proxy_url: typing.Optional[str] = None, + verify: typing.Optional[typing.Union[bool, str]] = None, +) -> 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, + audience=audience, + scopes=scopes, + device_code=resp.device_code, + http_proxy_url=http_proxy_url, + verify=verify, + ) + print("Authentication successful!") + return access_token, expires_in + except AuthenticationPending: + ... + except Exception as e: + logger.error("Authentication attempt failed: ", e) + raise e + print("Authentication Pending...") + time.sleep(interval.total_seconds()) + tick = tick + interval + raise AuthenticationError("Authentication failed!") diff --git a/flytekit/flytekit/clients/auth_helper.py b/flytekit/flytekit/clients/auth_helper.py new file mode 100644 index 0000000000..04028bc10a --- /dev/null +++ b/flytekit/flytekit/clients/auth_helper.py @@ -0,0 +1,297 @@ +import logging +import ssl +from http import HTTPStatus + +import grpc +import requests +from flyteidl.service.auth_pb2 import OAuth2MetadataRequest, PublicClientAuthConfigRequest +from flyteidl.service.auth_pb2_grpc import AuthMetadataServiceStub + +from flytekit.clients.auth.authenticator import ( + Authenticator, + ClientConfig, + ClientConfigStore, + ClientCredentialsAuthenticator, + CommandAuthenticator, + DeviceCodeAuthenticator, + PKCEAuthenticator, +) +from flytekit.clients.grpc_utils.auth_interceptor import AuthUnaryInterceptor +from flytekit.clients.grpc_utils.default_metadata_interceptor import DefaultMetadataInterceptor +from flytekit.clients.grpc_utils.wrap_exception_interceptor import RetryExceptionWrapperInterceptor +from flytekit.configuration import AuthType, PlatformConfig + + +class RemoteClientConfigStore(ClientConfigStore): + """ + This class implements the ClientConfigStore that is served by the Flyte Server, that implements AuthMetadataService + """ + + def __init__(self, secure_channel: grpc.Channel): + self._secure_channel = secure_channel + + def get_client_config(self) -> ClientConfig: + """ + Retrieves the ClientConfig from the given grpc.Channel assuming AuthMetadataService is available + """ + metadata_service = AuthMetadataServiceStub(self._secure_channel) + public_client_config = metadata_service.GetPublicClientConfig(PublicClientAuthConfigRequest()) + oauth2_metadata = metadata_service.GetOAuth2Metadata(OAuth2MetadataRequest()) + return ClientConfig( + token_endpoint=oauth2_metadata.token_endpoint, + authorization_endpoint=oauth2_metadata.authorization_endpoint, + redirect_uri=public_client_config.redirect_uri, + client_id=public_client_config.client_id, + scopes=public_client_config.scopes, + header_key=public_client_config.authorization_metadata_key or None, + device_authorization_endpoint=oauth2_metadata.device_authorization_endpoint, + audience=public_client_config.audience, + ) + + +def get_authenticator(cfg: PlatformConfig, cfg_store: ClientConfigStore) -> Authenticator: + """ + Returns a new authenticator based on the platform config. + """ + cfg_auth = cfg.auth_mode + if type(cfg_auth) is str: + try: + cfg_auth = AuthType[cfg_auth.upper()] + except KeyError: + logging.warning(f"Authentication type {cfg_auth} does not exist, defaulting to standard") + cfg_auth = AuthType.STANDARD + + verify = None + if cfg.insecure_skip_verify: + verify = False + elif cfg.ca_cert_file_path: + verify = cfg.ca_cert_file_path + + session = get_session(cfg) + + if cfg_auth == AuthType.STANDARD or cfg_auth == AuthType.PKCE: + return PKCEAuthenticator(cfg.endpoint, cfg_store, scopes=cfg.scopes, verify=verify, session=session) + elif cfg_auth == AuthType.BASIC or cfg_auth == AuthType.CLIENT_CREDENTIALS or cfg_auth == AuthType.CLIENTSECRET: + return ClientCredentialsAuthenticator( + endpoint=cfg.endpoint, + client_id=cfg.client_id, + client_secret=cfg.client_credentials_secret, + cfg_store=cfg_store, + scopes=cfg.scopes, + audience=cfg.audience, + http_proxy_url=cfg.http_proxy_url, + verify=verify, + session=session, + ) + elif cfg_auth == AuthType.EXTERNAL_PROCESS or cfg_auth == AuthType.EXTERNALCOMMAND: + client_cfg = None + if cfg_store: + client_cfg = cfg_store.get_client_config() + return CommandAuthenticator( + command=cfg.command, + header_key=client_cfg.header_key if client_cfg else None, + ) + elif cfg_auth == AuthType.DEVICEFLOW: + return DeviceCodeAuthenticator( + endpoint=cfg.endpoint, + cfg_store=cfg_store, + audience=cfg.audience, + scopes=cfg.scopes, + http_proxy_url=cfg.http_proxy_url, + verify=verify, + session=session, + ) + else: + raise ValueError( + f"Invalid auth mode [{cfg_auth}] specified." f"Please update the creds config to use a valid value" + ) + + +def get_proxy_authenticator(cfg: PlatformConfig) -> Authenticator: + return CommandAuthenticator( + command=cfg.proxy_command, + header_key="proxy-authorization", + ) + + +def upgrade_channel_to_proxy_authenticated(cfg: PlatformConfig, in_channel: grpc.Channel) -> grpc.Channel: + """ + If activated in the platform config, given a grpc.Channel, preferably a secure channel, it returns a composed + channel that uses Interceptor to perform authentication with a proxy infront of Flyte + :param cfg: PlatformConfig + :param in_channel: grpc.Channel Precreated channel + :return: grpc.Channel. New composite channel + """ + if cfg.proxy_command: + proxy_authenticator = get_proxy_authenticator(cfg) + return grpc.intercept_channel(in_channel, AuthUnaryInterceptor(proxy_authenticator)) + else: + return in_channel + + +def upgrade_channel_to_authenticated(cfg: PlatformConfig, in_channel: grpc.Channel) -> grpc.Channel: + """ + Given a grpc.Channel, preferably a secure channel, it returns a composed channel that uses Interceptor to + perform an Oauth2.0 Auth flow + :param cfg: PlatformConfig + :param in_channel: grpc.Channel Precreated channel + :return: grpc.Channel. New composite channel + """ + authenticator = get_authenticator(cfg, RemoteClientConfigStore(in_channel)) + return grpc.intercept_channel(in_channel, AuthUnaryInterceptor(authenticator)) + + +def get_authenticated_channel(cfg: PlatformConfig) -> grpc.Channel: + """ + Returns a new channel for the given config that is authenticated + """ + channel = ( + grpc.insecure_channel(cfg.endpoint) + if cfg.insecure + else grpc.secure_channel(cfg.endpoint, grpc.ssl_channel_credentials()) + ) # noqa + channel = upgrade_channel_to_proxy_authenticated(cfg, channel) + return upgrade_channel_to_authenticated(cfg, channel) + + +def bootstrap_creds_from_server(endpoint: str) -> grpc.ChannelCredentials: + """ + Retrieves the SSL cert from the remote and uses that. should be used only if insecure-skip-verify + """ + # Get port from endpoint or use 443 + endpoint_parts = endpoint.rsplit(":", 1) + if len(endpoint_parts) == 2 and endpoint_parts[1].isdigit(): + server_address = (endpoint_parts[0], endpoint_parts[1]) + else: + server_address = (endpoint, "443") + cert = ssl.get_server_certificate(server_address) # noqa + return grpc.ssl_channel_credentials(str.encode(cert)) + + +def get_channel(cfg: PlatformConfig, **kwargs) -> grpc.Channel: + """ + Creates a new grpc.Channel given a platformConfig. + It is possible to pass additional options to the underlying channel. Examples for various options are as below + + .. code-block:: python + + get_channel(cfg=PlatformConfig(...)) + + .. code-block:: python + :caption: Additional options to insecure / secure channel. Example `options` and `compression` refer to grpc guide + + get_channel(cfg=PlatformConfig(...), options=..., compression=...) + + .. code-block:: python + :caption: Create secure channel with custom `grpc.ssl_channel_credentials` + + get_channel(cfg=PlatformConfig(insecure=False,...), credentials=...) + + + :param cfg: PlatformConfig + :param kwargs: Optional arguments to be passed to channel method. Refer to usage example above + :return: grpc.Channel (secure / insecure) + """ + if cfg.insecure: + return grpc.intercept_channel(grpc.insecure_channel(cfg.endpoint, **kwargs), DefaultMetadataInterceptor()) + + credentials = None + if "credentials" not in kwargs: + if cfg.insecure_skip_verify: + credentials = bootstrap_creds_from_server(cfg.endpoint) + elif cfg.ca_cert_file_path: + with open(cfg.ca_cert_file_path, "rb") as f: + st_cert = f.read() + credentials = grpc.ssl_channel_credentials(st_cert) + else: + credentials = grpc.ssl_channel_credentials( + root_certificates=kwargs.get("root_certificates", None), + private_key=kwargs.get("private_key", None), + certificate_chain=kwargs.get("certificate_chain", None), + ) + else: + credentials = kwargs["credentials"] + return grpc.intercept_channel( + grpc.secure_channel( + target=cfg.endpoint, + credentials=credentials, + options=kwargs.get("options", None), + compression=kwargs.get("compression", None), + ), + DefaultMetadataInterceptor(), + ) + + +def wrap_exceptions_channel(cfg: PlatformConfig, in_channel: grpc.Channel) -> grpc.Channel: + """ + Wraps the input channel with RetryExceptionWrapperInterceptor. This wrapper will cover all + exceptions and raise Exception from the Family flytekit.exceptions + + .. note:: This channel should be usually the outermost channel. This channel will raise a FlyteException + + :param cfg: PlatformConfig + :param in_channel: grpc.Channel + :return: grpc.Channel + """ + return grpc.intercept_channel(in_channel, RetryExceptionWrapperInterceptor(max_retries=cfg.rpc_retries)) + + +class AuthenticationHTTPAdapter(requests.adapters.HTTPAdapter): + """ + A custom HTTPAdapter that adds authentication headers to requests of a session. + """ + + def __init__(self, authenticator, *args, **kwargs): + self.authenticator = authenticator + super().__init__(*args, **kwargs) + + def add_auth_header(self, request): + """ + Adds authentication headers to the request. + :param request: The request object to add headers to. + """ + if self.authenticator.get_credentials() is None: + self.authenticator.refresh_credentials() + + auth_header_key, auth_header_val = self.authenticator.fetch_grpc_call_auth_metadata() + request.headers[auth_header_key] = auth_header_val + + def send(self, request, *args, **kwargs): + """ + Sends the request with added authentication headers. + If the response returns a 401 status code, refreshes the credentials and retries the request. + :param request: The request object to send. + :return: The response object. + """ + self.add_auth_header(request) + response = super().send(request, *args, **kwargs) + if response.status_code == HTTPStatus.UNAUTHORIZED: + self.authenticator.refresh_credentials() + self.add_auth_header(request) + response = super().send(request, *args, **kwargs) + return response + + +def upgrade_session_to_proxy_authenticated(cfg: PlatformConfig, session: requests.Session) -> requests.Session: + """ + Given a requests.Session, it returns a new session that uses a custom HTTPAdapter to + perform authentication with a proxy infront of Flyte + + :param cfg: PlatformConfig + :param session: requests.Session Precreated session + :return: requests.Session. New session with custom HTTPAdapter mounted + """ + proxy_authenticator = get_proxy_authenticator(cfg) + adapter = AuthenticationHTTPAdapter(proxy_authenticator) + + session.mount("http://", adapter) + session.mount("https://", adapter) + return session + + +def get_session(cfg: PlatformConfig, **kwargs) -> requests.Session: + """Return a new session for the given platform config.""" + session = requests.Session() + if cfg.proxy_command: + session = upgrade_session_to_proxy_authenticated(cfg, session) + return session diff --git a/flytekit/flytekit/clients/friendly.py b/flytekit/flytekit/clients/friendly.py new file mode 100644 index 0000000000..3a516fc1d2 --- /dev/null +++ b/flytekit/flytekit/clients/friendly.py @@ -0,0 +1,1038 @@ +import datetime +import typing + +from flyteidl.admin import common_pb2 as _common_pb2 +from flyteidl.admin import execution_pb2 as _execution_pb2 +from flyteidl.admin import launch_plan_pb2 as _launch_plan_pb2 +from flyteidl.admin import matchable_resource_pb2 as _matchable_resource_pb2 +from flyteidl.admin import node_execution_pb2 as _node_execution_pb2 +from flyteidl.admin import project_domain_attributes_pb2 as _project_domain_attributes_pb2 +from flyteidl.admin import project_pb2 as _project_pb2 +from flyteidl.admin import task_execution_pb2 as _task_execution_pb2 +from flyteidl.admin import task_pb2 as _task_pb2 +from flyteidl.admin import workflow_attributes_pb2 as _workflow_attributes_pb2 +from flyteidl.admin import workflow_pb2 as _workflow_pb2 +from flyteidl.service import dataproxy_pb2 as _data_proxy_pb2 +from google.protobuf.duration_pb2 import Duration + +from flytekit.clients.raw import RawSynchronousFlyteClient as _RawSynchronousFlyteClient +from flytekit.models import common as _common +from flytekit.models import execution as _execution +from flytekit.models import filters as _filters +from flytekit.models import launch_plan as _launch_plan +from flytekit.models import node_execution as _node_execution +from flytekit.models import project as _project +from flytekit.models import task as _task +from flytekit.models.admin import common as _admin_common +from flytekit.models.admin import task_execution as _task_execution +from flytekit.models.admin import workflow as _workflow +from flytekit.models.core import identifier as _identifier + + +class SynchronousFlyteClient(_RawSynchronousFlyteClient): + """ + This is a low-level client that users can use to make direct gRPC service calls to the control plane. See the + :std:doc:`service spec `. This is more user-friendly interface than the + :py:class:`raw client ` so users should try to use this class + first. Create a client by :: + + SynchronousFlyteClient("your.domain:port", insecure=True) + # insecure should be True if your flyteadmin deployment doesn't have SSL enabled + + """ + + @property + def raw(self): + """ + Gives access to the raw client + :rtype: flytekit.clients.raw.RawSynchronousFlyteClient + """ + return super(SynchronousFlyteClient, self) + + #################################################################################################################### + # + # Task Endpoints + # + #################################################################################################################### + + def create_task(self, task_identifer, task_spec): + """ + This will create a task definition in the Admin database. Once successful, the task object can be + retrieved via the client or viewed via the UI or command-line interfaces. + + .. note :: + + Overwrites are not supported so any request for a given project, domain, name, and version that exists in + the database must match the existing definition exactly. Furthermore, as long as the request + remains identical, calling this method multiple times will result in success. + + :param flytekit.models.core.identifier.Identifier task_identifer: The identifier for this task. + :param flytekit.models.task.TaskSpec task_spec: This is the actual definition of the task that + should be created. + :raises flytekit.common.exceptions.user.FlyteEntityAlreadyExistsException: If an identical version of the + task is found, this exception is raised. The client might choose to ignore this exception because the + identical task is already registered. + :raises grpc.RpcError: + """ + super(SynchronousFlyteClient, self).create_task( + _task_pb2.TaskCreateRequest(id=task_identifer.to_flyte_idl(), spec=task_spec.to_flyte_idl()) + ) + + def list_task_ids_paginated(self, project, domain, limit=100, token=None, sort_by=None): + """ + This returns a page of identifiers for the tasks for a given project and domain. Filters can also be + specified. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param Text project: The namespace of the project to list. + :param Text domain: The domain space of the project to list. + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param Text token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.common.NamedEntityIdentifier], Text + """ + identifier_list = super(SynchronousFlyteClient, self).list_task_ids_paginated( + _common_pb2.NamedEntityIdentifierListRequest( + project=project, + domain=domain, + limit=limit, + token=token, + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_common.NamedEntityIdentifier.from_flyte_idl(identifier_pb) for identifier_pb in identifier_list.entities], + str(identifier_list.token), + ) + + def list_tasks_paginated(self, identifier, limit=100, token=None, filters=None, sort_by=None): + """ + This returns a page of task metadata for tasks in a given project and domain. Optionally, + specifying a name will limit the results to only tasks with that name in the given project and domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param flytekit.models.common.NamedEntityIdentifier identifier: NamedEntityIdentifier to list. + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param int token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param list[flytekit.models.filters.Filter] filters: [Optional] If specified, the filters will be applied to + the query. If the filter is not supported, an exception will be raised. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.task.Task], Text + """ + task_list = super(SynchronousFlyteClient, self).list_tasks_paginated( + resource_list_request=_common_pb2.ResourceListRequest( + id=identifier.to_flyte_idl(), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + # TODO: tmp workaround + for pb in task_list.tasks: + pb.id.resource_type = _identifier.ResourceType.TASK + return ( + [_task.Task.from_flyte_idl(task_pb2) for task_pb2 in task_list.tasks], + str(task_list.token), + ) + + def get_task(self, id): + """ + This returns a single task for a given identifier. + + :param flytekit.models.core.identifier.Identifier id: The ID representing a given task. + :raises: TODO + :rtype: flytekit.models.task.Task + """ + return _task.Task.from_flyte_idl( + super(SynchronousFlyteClient, self).get_task(_common_pb2.ObjectGetRequest(id=id.to_flyte_idl())) + ) + + #################################################################################################################### + # + # Workflow Endpoints + # + #################################################################################################################### + + def create_workflow(self, workflow_identifier, workflow_spec): + """ + This will create a workflow definition in the Admin database. Once successful, the workflow object can be + retrieved via the client or viewed via the UI or command-line interfaces. + + .. note :: + + Overwrites are not supported so any request for a given project, domain, name, and version that exists in + the database must match the existing definition exactly. Furthermore, as long as the request + remains identical, calling this method multiple times will result in success. + + :param: flytekit.models.core.identifier.Identifier workflow_identifier: The identifier for this workflow. + :param: flytekit.models.admin.workflow.WorkflowSpec workflow_spec: This is the actual definition of the workflow + that should be created. + :raises flytekit.common.exceptions.user.FlyteEntityAlreadyExistsException: If an identical version of the + workflow is found, this exception is raised. The client might choose to ignore this exception because the + identical workflow is already registered. + :raises grpc.RpcError: + """ + super(SynchronousFlyteClient, self).create_workflow( + _workflow_pb2.WorkflowCreateRequest( + id=workflow_identifier.to_flyte_idl(), spec=workflow_spec.to_flyte_idl() + ) + ) + + def list_workflow_ids_paginated(self, project, domain, limit=100, token=None, sort_by=None): + """ + This returns a page of identifiers for the workflows for a given project and domain. Filters can also be + specified. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param: Text project: The namespace of the project to list. + :param: Text domain: The domain space of the project to list. + :param: int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param: int token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.common.NamedEntityIdentifier], Text + """ + identifier_list = super(SynchronousFlyteClient, self).list_workflow_ids_paginated( + _common_pb2.NamedEntityIdentifierListRequest( + project=project, + domain=domain, + limit=limit, + token=token, + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_common.NamedEntityIdentifier.from_flyte_idl(identifier_pb) for identifier_pb in identifier_list.entities], + str(identifier_list.token), + ) + + def list_workflows_paginated(self, identifier, limit=100, token=None, filters=None, sort_by=None): + """ + This returns a page of workflow meta-information for workflows in a given project and domain. Optionally, + specifying a name will limit the results to only workflows with that name in the given project and domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param flytekit.models.common.NamedEntityIdentifier identifier: NamedEntityIdentifier to list. + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param int token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param list[flytekit.models.filters.Filter] filters: [Optional] If specified, the filters will be applied to + the query. If the filter is not supported, an exception will be raised. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.admin.workflow.Workflow], Text + """ + wf_list = super(SynchronousFlyteClient, self).list_workflows_paginated( + resource_list_request=_common_pb2.ResourceListRequest( + id=identifier.to_flyte_idl(), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + # TODO: tmp workaround + for pb in wf_list.workflows: + pb.id.resource_type = _identifier.ResourceType.WORKFLOW + return ( + [_workflow.Workflow.from_flyte_idl(wf_pb2) for wf_pb2 in wf_list.workflows], + str(wf_list.token), + ) + + def get_workflow(self, id): + """ + This returns a single workflow for a given ID. + + :param flytekit.models.core.identifier.Identifier id: The ID representing a given task. + :raises: TODO + :rtype: flytekit.models.admin.workflow.Workflow + """ + return _workflow.Workflow.from_flyte_idl( + super(SynchronousFlyteClient, self).get_workflow(_common_pb2.ObjectGetRequest(id=id.to_flyte_idl())) + ) + + #################################################################################################################### + # + # Launch Plan Endpoints + # + #################################################################################################################### + + def create_launch_plan(self, launch_plan_identifer, launch_plan_spec): + """ + This will create a launch plan definition in the Admin database. Once successful, the launch plan object can be + retrieved via the client or viewed via the UI or command-line interfaces. + + .. note :: + + Overwrites are not supported so any request for a given project, domain, name, and version that exists in + the database must match the existing definition exactly. This also means that as long as the request + remains identical, calling this method multiple times will result in success. + + :param: flytekit.models.core.identifier.Identifier launch_plan_identifer: The identifier for this launch plan. + :param: flytekit.models.launch_plan.LaunchPlanSpec launch_plan_spec: This is the actual definition of the + launch plan that should be created. + :raises flytekit.common.exceptions.user.FlyteEntityAlreadyExistsException: If an identical version of the + launch plan is found, this exception is raised. The client might choose to ignore this exception because + the identical launch plan is already registered. + :raises grpc.RpcError: + """ + super(SynchronousFlyteClient, self).create_launch_plan( + _launch_plan_pb2.LaunchPlanCreateRequest( + id=launch_plan_identifer.to_flyte_idl(), + spec=launch_plan_spec.to_flyte_idl(), + ) + ) + + def get_launch_plan(self, id): + """ + Retrieves a launch plan entity. + + :param flytekit.models.core.identifier.Identifier id: unique identifier for launch plan to retrieve + :rtype: flytekit.models.launch_plan.LaunchPlan + """ + return _launch_plan.LaunchPlan.from_flyte_idl( + super(SynchronousFlyteClient, self).get_launch_plan(_common_pb2.ObjectGetRequest(id=id.to_flyte_idl())) + ) + + def get_active_launch_plan(self, identifier): + """ + Retrieves the active launch plan entity given a named entity identifier (project, domain, name). Raises an + error if no active launch plan exists. + + :param flytekit.models.common.NamedEntityIdentifier identifier: NamedEntityIdentifier to list. + :rtype: flytekit.models.launch_plan.LaunchPlan + """ + return _launch_plan.LaunchPlan.from_flyte_idl( + super(SynchronousFlyteClient, self).get_active_launch_plan( + _launch_plan_pb2.ActiveLaunchPlanRequest(id=identifier.to_flyte_idl()) + ) + ) + + def list_launch_plan_ids_paginated(self, project, domain, limit=100, token=None, sort_by=None): + """ + This returns a page of identifiers for the launch plans for a given project and domain. Filters can also be + specified. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param: Text project: The namespace of the project to list. + :param: Text domain: The domain space of the project to list. + :param: int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param: int token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.common.NamedEntityIdentifier], Text + """ + identifier_list = super(SynchronousFlyteClient, self).list_launch_plan_ids_paginated( + _common_pb2.NamedEntityIdentifierListRequest( + project=project, + domain=domain, + limit=limit, + token=token, + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_common.NamedEntityIdentifier.from_flyte_idl(identifier_pb) for identifier_pb in identifier_list.entities], + str(identifier_list.token), + ) + + def list_launch_plans_paginated(self, identifier, limit=100, token=None, filters=None, sort_by=None): + """ + This returns a page of launch plan meta-information for launch plans in a given project and domain. Optionally, + specifying a name will limit the results to only workflows with that name in the given project and domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param flytekit.models.common.NamedEntityIdentifier identifier: NamedEntityIdentifier to list. + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param int token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param list[flytekit.models.filters.Filter] filters: [Optional] If specified, the filters will be applied to + the query. If the filter is not supported, an exception will be raised. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.launch_plan.LaunchPlan], str + """ + lp_list = super(SynchronousFlyteClient, self).list_launch_plans_paginated( + resource_list_request=_common_pb2.ResourceListRequest( + id=identifier.to_flyte_idl(), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + # TODO: tmp workaround + for pb in lp_list.launch_plans: + pb.id.resource_type = _identifier.ResourceType.LAUNCH_PLAN + return ( + [_launch_plan.LaunchPlan.from_flyte_idl(pb) for pb in lp_list.launch_plans], + str(lp_list.token), + ) + + def list_active_launch_plans_paginated( + self, project, domain, limit=100, token=None, sort_by=None + ) -> typing.Tuple[typing.List[_launch_plan.LaunchPlan], str]: + """ + This returns a page of currently active launch plan meta-information for launch plans in a given project and + domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param Text project: + :param Text domain: + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param int token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: list[flytekit.models.launch_plan.LaunchPlan], str + """ + lp_list = super(SynchronousFlyteClient, self).list_active_launch_plans_paginated( + _launch_plan_pb2.ActiveLaunchPlanListRequest( + project=project, + domain=domain, + limit=limit, + token=token, + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + # TODO: tmp workaround + for pb in lp_list.launch_plans: + pb.id.resource_type = _identifier.ResourceType.LAUNCH_PLAN + return ( + [_launch_plan.LaunchPlan.from_flyte_idl(pb) for pb in lp_list.launch_plans], + str(lp_list.token), + ) + + def update_launch_plan(self, id, state): + """ + Updates a launch plan. Currently, this can only be used to update a given launch plan's state (ACTIVE v. + INACTIVE) for schedules. If a launch plan with a given project, domain, and name is set to ACTIVE, + then any other launch plan with the same project, domain, and name that was set to ACTIVE will be switched to + INACTIVE in one transaction. + + :param flytekit.models.core.identifier.Identifier id: identifier for launch plan to update + :param int state: Enum value from flytekit.models.launch_plan.LaunchPlanState + """ + super(SynchronousFlyteClient, self).update_launch_plan( + _launch_plan_pb2.LaunchPlanUpdateRequest(id=id.to_flyte_idl(), state=state) + ) + + #################################################################################################################### + # + # Named Entity Endpoints + # + #################################################################################################################### + + def update_named_entity(self, resource_type, id, metadata): + """ + Updates the metadata associated with a named entity. A named entity is designated a resource, e.g. a workflow, + task or launch plan specified by {project, domain, name} across all versions of the resource. + + :param int resource_type: Enum value from flytekit.models.identifier.ResourceType + :param flytekit.models.admin.named_entity.NamedEntityIdentifier id: identifier for named entity to update + :param flytekit.models.admin.named_entity.NamedEntityIdentifierMetadata metadata: + """ + super(SynchronousFlyteClient, self).update_named_entity( + _common_pb2.NamedEntityUpdateRequest( + resource_type=resource_type, + id=id.to_flyte_idl(), + metadata=metadata.to_flyte_idl(), + ) + ) + + #################################################################################################################### + # + # Execution Endpoints + # + #################################################################################################################### + + def create_execution(self, project, domain, name, execution_spec, inputs): + """ + This will create an execution for the given execution spec. + :param Text project: + :param Text domain: + :param Text name: + :param flytekit.models.execution.ExecutionSpec execution_spec: This is the specification for the execution. + :param flytekit.models.literals.LiteralMap inputs: The inputs for the execution + :returns: The unique identifier for the execution. + :rtype: flytekit.models.core.identifier.WorkflowExecutionIdentifier + """ + return _identifier.WorkflowExecutionIdentifier.from_flyte_idl( + super(SynchronousFlyteClient, self) + .create_execution( + _execution_pb2.ExecutionCreateRequest( + project=project, + domain=domain, + name=name, + spec=execution_spec.to_flyte_idl(), + inputs=inputs.to_flyte_idl(), + ) + ) + .id + ) + + def recover_execution(self, id, name: str = None): + """ + Recreates a previously-run workflow execution that will only start executing from the last known failure point. + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier id: + :param name str: Optional name to assign to the newly created execution. + :rtype: flytekit.models.core.identifier.WorkflowExecutionIdentifier + """ + return _identifier.WorkflowExecutionIdentifier.from_flyte_idl( + super(SynchronousFlyteClient, self) + .recover_execution(_execution_pb2.ExecutionRecoverRequest(id=id.to_flyte_idl(), name=name)) + .id + ) + + def get_execution(self, id): + """ + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier id: + :rtype: flytekit.models.execution.Execution + """ + return _execution.Execution.from_flyte_idl( + super(SynchronousFlyteClient, self).get_execution( + _execution_pb2.WorkflowExecutionGetRequest(id=id.to_flyte_idl()) + ) + ) + + def get_execution_data(self, id): + """ + Returns signed URLs to LiteralMap blobs for an execution's inputs and outputs (when available). + + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier id: + :rtype: flytekit.models.execution.WorkflowExecutionGetDataResponse + """ + return _execution.WorkflowExecutionGetDataResponse.from_flyte_idl( + super(SynchronousFlyteClient, self).get_execution_data( + _execution_pb2.WorkflowExecutionGetDataRequest(id=id.to_flyte_idl()) + ) + ) + + def list_executions_paginated(self, project, domain, limit=100, token=None, filters=None, sort_by=None): + """ + This returns a page of executions in a given project and domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param Text project: Project in which to list executions. + :param Text domain: Project in which to list executions. + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param Text token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param list[flytekit.models.filters.Filter] filters: [Optional] If specified, the filters will be applied to + the query. If the filter is not supported, an exception will be raised. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises: TODO + :rtype: (list[flytekit.models.execution.Execution], Text) + """ + exec_list = super(SynchronousFlyteClient, self).list_executions_paginated( + resource_list_request=_common_pb2.ResourceListRequest( + id=_common_pb2.NamedEntityIdentifier(project=project, domain=domain), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_execution.Execution.from_flyte_idl(pb) for pb in exec_list.executions], + str(exec_list.token), + ) + + def terminate_execution(self, id, cause): + """ + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier id: + :param Text cause: + """ + super(SynchronousFlyteClient, self).terminate_execution( + _execution_pb2.ExecutionTerminateRequest(id=id.to_flyte_idl(), cause=cause) + ) + + def relaunch_execution(self, id, name=None): + """ + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier id: + :param Text name: [Optional] name for the new execution. If not specified, a randomly generated name will be + used + :returns: The unique identifier for the new execution. + :rtype: flytekit.models.core.identifier.WorkflowExecutionIdentifier + """ + return _identifier.WorkflowExecutionIdentifier.from_flyte_idl( + super(SynchronousFlyteClient, self) + .relaunch_execution(_execution_pb2.ExecutionRelaunchRequest(id=id.to_flyte_idl(), name=name)) + .id + ) + + #################################################################################################################### + # + # Node Execution Endpoints + # + #################################################################################################################### + + def get_node_execution(self, node_execution_identifier): + """ + :param flytekit.models.core.identifier.NodeExecutionIdentifier node_execution_identifier: + :rtype: flytekit.models.node_execution.NodeExecution + """ + return _node_execution.NodeExecution.from_flyte_idl( + super(SynchronousFlyteClient, self).get_node_execution( + _node_execution_pb2.NodeExecutionGetRequest(id=node_execution_identifier.to_flyte_idl()) + ) + ) + + def get_node_execution_data(self, node_execution_identifier) -> _execution.NodeExecutionGetDataResponse: + """ + Returns signed URLs to LiteralMap blobs for a node execution's inputs and outputs (when available). + + :param flytekit.models.core.identifier.NodeExecutionIdentifier node_execution_identifier: + """ + return _execution.NodeExecutionGetDataResponse.from_flyte_idl( + super(SynchronousFlyteClient, self).get_node_execution_data( + _node_execution_pb2.NodeExecutionGetDataRequest(id=node_execution_identifier.to_flyte_idl()) + ) + ) + + def list_node_executions( + self, + workflow_execution_identifier, + limit: int = 100, + token: typing.Optional[str] = None, + filters: typing.List[_filters.Filter] = None, + sort_by: _admin_common.Sort = None, + unique_parent_id: str = None, + ): + """Get node executions associated with a given workflow execution. + + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier workflow_execution_identifier: + :param limit: Limit the number of items returned in the response. + :param token: If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify ``token="foo"``. + :param list[flytekit.models.filters.Filter] filters: + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :param unique_parent_id: If specified, returns the node executions for the ``unique_parent_id`` node id. + :rtype: list[flytekit.models.node_execution.NodeExecution], Text + """ + exec_list = super(SynchronousFlyteClient, self).list_node_executions_paginated( + _node_execution_pb2.NodeExecutionListRequest( + workflow_execution_id=workflow_execution_identifier.to_flyte_idl(), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + unique_parent_id=unique_parent_id, + ) + ) + return ( + [_node_execution.NodeExecution.from_flyte_idl(e) for e in exec_list.node_executions], + str(exec_list.token), + ) + + def list_node_executions_for_task_paginated( + self, + task_execution_identifier, + limit=100, + token=None, + filters=None, + sort_by=None, + ): + """ + This returns nodes spawned by a specific task execution. This is generally from things like dynamic tasks. + :param flytekit.models.core.identifier.TaskExecutionIdentifier task_execution_identifier: + :param int limit: Number to return per page + :param Text token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". + :param list[flytekit.models.filters.Filter] filters: + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :rtype: list[flytekit.models.node_execution.NodeExecution], Text + """ + exec_list = self._stub.ListNodeExecutionsForTask( + _node_execution_pb2.NodeExecutionForTaskListRequest( + task_execution_id=task_execution_identifier.to_flyte_idl(), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_node_execution.NodeExecution.from_flyte_idl(e) for e in exec_list.node_executions], + str(exec_list.token), + ) + + #################################################################################################################### + # + # Task Execution Endpoints + # + #################################################################################################################### + + def get_task_execution(self, id): + """ + :param flytekit.models.core.identifier.TaskExecutionIdentifier id: + :rtype: flytekit.models.admin.task_execution.TaskExecution + """ + return _task_execution.TaskExecution.from_flyte_idl( + super(SynchronousFlyteClient, self).get_task_execution( + _task_execution_pb2.TaskExecutionGetRequest(id=id.to_flyte_idl()) + ) + ) + + def get_task_execution_data(self, task_execution_identifier): + """ + Returns signed URLs to LiteralMap blobs for a node execution's inputs and outputs (when available). + + :param flytekit.models.core.identifier.TaskExecutionIdentifier task_execution_identifier: + :rtype: flytekit.models.execution.NodeExecutionGetDataResponse + """ + return _execution.TaskExecutionGetDataResponse.from_flyte_idl( + super(SynchronousFlyteClient, self).get_task_execution_data( + _task_execution_pb2.TaskExecutionGetDataRequest(id=task_execution_identifier.to_flyte_idl()) + ) + ) + + def list_task_executions_paginated( + self, + node_execution_identifier, + limit=100, + token=None, + filters=None, + sort_by=None, + ): + """ + :param flytekit.models.core.identifier.NodeExecutionIdentifier node_execution_identifier: + :param int limit: + :param Text token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". + :param list[flytekit.models.filters.Filter] filters: + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :rtype: (list[flytekit.models.admin.task_execution.TaskExecution], Text) + """ + exec_list = super(SynchronousFlyteClient, self).list_task_executions_paginated( + _task_execution_pb2.TaskExecutionListRequest( + node_execution_id=node_execution_identifier.to_flyte_idl(), + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_task_execution.TaskExecution.from_flyte_idl(e) for e in exec_list.task_executions], + str(exec_list.token), + ) + + #################################################################################################################### + # + # Project Endpoints + # + #################################################################################################################### + + def register_project(self, project): + """ + Registers a project. + :param flytekit.models.project.Project project: + :rtype: flyteidl.admin.project_pb2.ProjectRegisterResponse + """ + super(SynchronousFlyteClient, self).register_project( + _project_pb2.ProjectRegisterRequest( + project=project.to_flyte_idl(), + ) + ) + + def update_project(self, project): + """ + Update an existing project specified by id. + :param flytekit.models.project.Project project: + :rtype: flyteidl.admin.project_pb2.ProjectUpdateResponse + """ + super(SynchronousFlyteClient, self).update_project(project.to_flyte_idl()) + + def list_projects_paginated(self, limit=100, token=None, filters=None, sort_by=None): + """ + This returns a page of projects. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param int limit: [Optional] The maximum number of entries to return. Must be greater than 0. The maximum + page size is determined by the Flyte Admin Service configuration. If limit is greater than the maximum + page size, an exception will be raised. + :param Text token: [Optional] If specified, this specifies where in the rows of results to skip before reading. + If you previously retrieved a page response with token="foo" and you want the next page, + specify token="foo". Please see the notes for this function about the caveats of the paginated API. + :param list[flytekit.models.filters.Filter] filters: [Optional] If specified, the filters will be applied to + the query. If the filter is not supported, an exception will be raised. + :param flytekit.models.admin.common.Sort sort_by: [Optional] If provided, the results will be sorted. + :raises grpc.RpcError: + :rtype: (list[flytekit.models.Project], Text) + """ + projects = super(SynchronousFlyteClient, self).list_projects( + project_list_request=_project_pb2.ProjectListRequest( + limit=limit, + token=token, + filters=_filters.FilterList(filters or []).to_flyte_idl(), + sort_by=None if sort_by is None else sort_by.to_flyte_idl(), + ) + ) + return ( + [_project.Project.from_flyte_idl(pb) for pb in projects.projects], + str(projects.token), + ) + + #################################################################################################################### + # + # Matching Attributes Endpoints + # + #################################################################################################################### + + def update_project_domain_attributes(self, project, domain, matching_attributes): + """ + Sets custom attributes for a project and domain combination. + :param Text project: + :param Text domain: + :param flytekit.models.MatchingAttributes matching_attributes: + :return: + """ + super(SynchronousFlyteClient, self).update_project_domain_attributes( + _project_domain_attributes_pb2.ProjectDomainAttributesUpdateRequest( + attributes=_project_domain_attributes_pb2.ProjectDomainAttributes( + project=project, + domain=domain, + matching_attributes=matching_attributes.to_flyte_idl(), + ) + ) + ) + + def update_workflow_attributes(self, project, domain, workflow, matching_attributes): + """ + Sets custom attributes for a project, domain, and workflow combination. + :param Text project: + :param Text domain: + :param Text workflow: + :param flytekit.models.MatchingAttributes matching_attributes: + :return: + """ + super(SynchronousFlyteClient, self).update_workflow_attributes( + _workflow_attributes_pb2.WorkflowAttributesUpdateRequest( + attributes=_workflow_attributes_pb2.WorkflowAttributes( + project=project, + domain=domain, + workflow=workflow, + matching_attributes=matching_attributes.to_flyte_idl(), + ) + ) + ) + + def get_project_domain_attributes(self, project, domain, resource_type): + """ + Fetches the custom attributes set for a project and domain combination. + :param Text project: + :param Text domain: + :param flytekit.models.MatchableResource resource_type: + :return: + """ + return super(SynchronousFlyteClient, self).get_project_domain_attributes( + _project_domain_attributes_pb2.ProjectDomainAttributesGetRequest( + project=project, + domain=domain, + resource_type=resource_type, + ) + ) + + def get_workflow_attributes(self, project, domain, workflow, resource_type): + """ + Fetches the custom attributes set for a project, domain, and workflow combination. + :param Text project: + :param Text domain: + :param Text workflow: + :param flytekit.models.MatchableResource resource_type: + :return: + """ + return super(SynchronousFlyteClient, self).get_workflow_attributes( + _workflow_attributes_pb2.WorkflowAttributesGetRequest( + project=project, + domain=domain, + workflow=workflow, + resource_type=resource_type, + ) + ) + + def list_matchable_attributes(self, resource_type): + """ + Fetches all custom attributes for a resource type. + :param flytekit.models.MatchableResource resource_type: + :return: + """ + return super(SynchronousFlyteClient, self).list_matchable_attributes( + _matchable_resource_pb2.ListMatchableAttributesRequest( + resource_type=resource_type, + ) + ) + + def get_upload_signed_url( + self, + project: str, + domain: str, + content_md5: typing.Optional[bytes] = None, + filename: typing.Optional[str] = None, + expires_in: typing.Optional[datetime.timedelta] = None, + filename_root: typing.Optional[str] = None, + ) -> _data_proxy_pb2.CreateUploadLocationResponse: + """ + Get a signed url to be used during fast registration + + :param project: Project to create the upload location for + :param domain: Domain to create the upload location for + :param content_md5: ContentMD5 restricts the upload location to the specific MD5 provided. The content_md5 + will also appear in the generated path. + :param filename: If provided this specifies a desired suffix for the generated location + :param expires_in: If provided this defines a requested expiration duration for + the generated url + :param filename_root: If provided will be used as the root of the filename. If not, Admin will use a hash + This option is useful when uploading a series of files that you want to be grouped together. + :rtype: flyteidl.service.dataproxy_pb2.CreateUploadLocationResponse + """ + expires_in_pb = None + if expires_in: + expires_in_pb = Duration() + expires_in_pb.FromTimedelta(expires_in) + return super(SynchronousFlyteClient, self).create_upload_location( + _data_proxy_pb2.CreateUploadLocationRequest( + project=project, + domain=domain, + content_md5=content_md5, + filename=filename, + expires_in=expires_in_pb, + filename_root=filename_root, + ) + ) + + def get_download_signed_url( + self, native_url: str, expires_in: datetime.timedelta = None + ) -> _data_proxy_pb2.CreateDownloadLocationResponse: + expires_in_pb = None + if expires_in: + expires_in_pb = Duration() + expires_in_pb.FromTimedelta(expires_in) + return super(SynchronousFlyteClient, self).create_download_location( + _data_proxy_pb2.CreateDownloadLocationRequest( + native_url=native_url, + expires_in=expires_in_pb, + ) + ) + + def get_data(self, flyte_uri: str) -> _data_proxy_pb2.GetDataResponse: + req = _data_proxy_pb2.GetDataRequest(flyte_url=flyte_uri) + + resp = self._dataproxy_stub.GetData(req, metadata=self._metadata) + return resp diff --git a/flytekit/flytekit/clients/grpc_utils/__init__.py b/flytekit/flytekit/clients/grpc_utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/clients/grpc_utils/auth_interceptor.py b/flytekit/flytekit/clients/grpc_utils/auth_interceptor.py new file mode 100644 index 0000000000..e467801a77 --- /dev/null +++ b/flytekit/flytekit/clients/grpc_utils/auth_interceptor.py @@ -0,0 +1,80 @@ +import typing +from collections import namedtuple + +import grpc + +from flytekit.clients.auth.authenticator import Authenticator + + +class _ClientCallDetails( + namedtuple("_ClientCallDetails", ("method", "timeout", "metadata", "credentials")), + grpc.ClientCallDetails, +): + """ + Wrapper class for initializing a new ClientCallDetails instance. + We cannot make this of type - NamedTuple because, NamedTuple has a metaclass of type NamedTupleMeta and both + the metaclasses conflict + """ + + pass + + +class AuthUnaryInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor): + """ + This Interceptor can be used to automatically add Auth Metadata for every call - lazily in case authentication + is needed. + """ + + def __init__(self, authenticator: Authenticator): + self._authenticator = authenticator + + def _call_details_with_auth_metadata(self, client_call_details: grpc.ClientCallDetails) -> grpc.ClientCallDetails: + """ + Returns new ClientCallDetails with metadata added. + """ + metadata = client_call_details.metadata + auth_metadata = self._authenticator.fetch_grpc_call_auth_metadata() + if auth_metadata: + metadata = [] + if client_call_details.metadata: + metadata.extend(list(client_call_details.metadata)) + metadata.append(auth_metadata) + + return _ClientCallDetails( + client_call_details.method, + client_call_details.timeout, + metadata, + client_call_details.credentials, + ) + + def intercept_unary_unary( + self, + continuation: typing.Callable, + client_call_details: grpc.ClientCallDetails, + request: typing.Any, + ): + """ + Intercepts unary calls and adds auth metadata if available. On Unauthenticated, resets the token and refreshes + and then retries with the new token + """ + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + fut: grpc.Future = continuation(updated_call_details, request) + e = fut.exception() + if e: + if e.code() == grpc.StatusCode.UNAUTHENTICATED or e.code() == grpc.StatusCode.UNKNOWN: + self._authenticator.refresh_credentials() + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + return continuation(updated_call_details, request) + return fut + + def intercept_unary_stream(self, continuation, client_call_details, request): + """ + Handles a stream call and adds authentication metadata if needed + """ + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + c: grpc.Call = continuation(updated_call_details, request) + if c.code() == grpc.StatusCode.UNAUTHENTICATED: + self._authenticator.refresh_credentials() + updated_call_details = self._call_details_with_auth_metadata(client_call_details) + return continuation(updated_call_details, request) + return c diff --git a/flytekit/flytekit/clients/grpc_utils/default_metadata_interceptor.py b/flytekit/flytekit/clients/grpc_utils/default_metadata_interceptor.py new file mode 100644 index 0000000000..12b06cca03 --- /dev/null +++ b/flytekit/flytekit/clients/grpc_utils/default_metadata_interceptor.py @@ -0,0 +1,43 @@ +import typing + +import grpc + +from flytekit.clients.grpc_utils.auth_interceptor import _ClientCallDetails + + +class DefaultMetadataInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor): + def _inject_default_metadata(self, call_details: grpc.ClientCallDetails): + metadata = [("accept", "application/grpc")] + if call_details.metadata: + metadata.extend(list(call_details.metadata)) + new_details = _ClientCallDetails( + call_details.method, + call_details.timeout, + metadata, + call_details.credentials, + ) + return new_details + + def intercept_unary_unary( + self, + continuation: typing.Callable, + client_call_details: grpc.ClientCallDetails, + request: typing.Any, + ): + """ + Intercepts unary calls and inject default metadata + """ + updated_call_details = self._inject_default_metadata(client_call_details) + return continuation(updated_call_details, request) + + def intercept_unary_stream( + self, + continuation: typing.Callable, + client_call_details: grpc.ClientCallDetails, + request: typing.Any, + ): + """ + Handles a stream call and inject default metadata + """ + updated_call_details = self._inject_default_metadata(client_call_details) + return continuation(updated_call_details, request) diff --git a/flytekit/flytekit/clients/grpc_utils/wrap_exception_interceptor.py b/flytekit/flytekit/clients/grpc_utils/wrap_exception_interceptor.py new file mode 100644 index 0000000000..ea796f464a --- /dev/null +++ b/flytekit/flytekit/clients/grpc_utils/wrap_exception_interceptor.py @@ -0,0 +1,49 @@ +import typing +from typing import Union + +import grpc + +from flytekit.exceptions.base import FlyteException +from flytekit.exceptions.system import FlyteSystemException +from flytekit.exceptions.user import ( + FlyteAuthenticationException, + FlyteEntityAlreadyExistsException, + FlyteEntityNotExistException, + FlyteInvalidInputException, +) + + +class RetryExceptionWrapperInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor): + def __init__(self, max_retries: int = 3): + self._max_retries = 3 + + @staticmethod + def _raise_if_exc(request: typing.Any, e: Union[grpc.Call, grpc.Future]): + if isinstance(e, grpc.RpcError): + if e.code() == grpc.StatusCode.UNAUTHENTICATED: + raise FlyteAuthenticationException() from e + elif e.code() == grpc.StatusCode.ALREADY_EXISTS: + raise FlyteEntityAlreadyExistsException() from e + elif e.code() == grpc.StatusCode.NOT_FOUND: + raise FlyteEntityNotExistException() from e + elif e.code() == grpc.StatusCode.INVALID_ARGUMENT: + raise FlyteInvalidInputException(request) from e + raise FlyteSystemException() from e + + def intercept_unary_unary(self, continuation, client_call_details, request): + retries = 0 + while True: + fut: grpc.Future = continuation(client_call_details, request) + e = fut.exception() + try: + if e: + self._raise_if_exc(request, e) + return fut + except FlyteException as e: + if retries == self._max_retries: + raise e + retries = retries + 1 + + def intercept_unary_stream(self, continuation, client_call_details, request): + c: grpc.Call = continuation(client_call_details, request) + return c diff --git a/flytekit/flytekit/clients/helpers.py b/flytekit/flytekit/clients/helpers.py new file mode 100644 index 0000000000..2df64f080e --- /dev/null +++ b/flytekit/flytekit/clients/helpers.py @@ -0,0 +1,77 @@ +def iterate_node_executions( + client, + workflow_execution_identifier=None, + task_execution_identifier=None, + limit=None, + filters=None, + unique_parent_id=None, +): + """ + This returns a generator for node executions. + :param flytekit.clients.friendly.SynchronousFlyteClient client: + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier workflow_execution_identifier: + :param flytekit.models.core.identifier.TaskExecutionIdentifier task_execution_identifier: + :param int limit: The maximum number of elements to retrieve + :param list[flytekit.models.filters.Filter] filters: + :rtype: Iterator[flytekit.models.node_execution.NodeExecution] + """ + token = "" + num_to_fetch = 100 + if limit is not None and limit < num_to_fetch: + num_to_fetch = limit + counter = 0 + while True: + if workflow_execution_identifier is not None: + node_execs, next_token = client.list_node_executions( + workflow_execution_identifier=workflow_execution_identifier, + limit=num_to_fetch, + token=token, + filters=filters, + unique_parent_id=unique_parent_id, + ) + else: + node_execs, next_token = client.list_node_executions_for_task_paginated( + task_execution_identifier=task_execution_identifier, + limit=num_to_fetch, + token=token, + filters=filters, + ) + for n in node_execs: + counter += 1 + if limit is not None and counter > limit: + return + yield n + if not next_token: + break + token = next_token + + +def iterate_task_executions(client, node_execution_identifier, limit=None, filters=None): + """ + This returns a generator for task executions, given a node execution identifier + :param flytekit.clients.friendly.SynchronousFlyteClient client: + :param flytekit.models.core.identifier.NodeExecutionIdentifier node_execution_identifier: + :param int limit: The maximum number of elements to retrieve + :param list[flytekit.models.filters.Filter] filters: + :rtype: Iterator[flytekit.models.admin.task_execution.TaskExecution] + """ + token = "" + num_to_fetch = 100 + if limit is not None and limit < num_to_fetch: + num_to_fetch = limit + counter = 0 + while True: + task_execs, next_token = client.list_task_executions_paginated( + node_execution_identifier=node_execution_identifier, + limit=num_to_fetch, + token=token, + filters=filters, + ) + for t in task_execs: + counter += 1 + if limit is not None and counter > limit: + return + yield t + if not next_token: + break + token = next_token diff --git a/flytekit/flytekit/clients/raw.py b/flytekit/flytekit/clients/raw.py new file mode 100644 index 0000000000..681a1b0071 --- /dev/null +++ b/flytekit/flytekit/clients/raw.py @@ -0,0 +1,594 @@ +from __future__ import annotations + +import typing + +import grpc +from flyteidl.admin.project_pb2 import ProjectListRequest +from flyteidl.admin.signal_pb2 import SignalList, SignalListRequest, SignalSetRequest, SignalSetResponse +from flyteidl.service import admin_pb2_grpc as _admin_service +from flyteidl.service import dataproxy_pb2 as _dataproxy_pb2 +from flyteidl.service import dataproxy_pb2_grpc as dataproxy_service +from flyteidl.service import signal_pb2_grpc as signal_service +from flyteidl.service.dataproxy_pb2_grpc import DataProxyServiceStub + +from flytekit.clients.auth_helper import ( + get_channel, + upgrade_channel_to_authenticated, + upgrade_channel_to_proxy_authenticated, + wrap_exceptions_channel, +) +from flytekit.configuration import PlatformConfig +from flytekit.loggers import logger + + +class RawSynchronousFlyteClient(object): + """ + This is a thin synchronous wrapper around the auto-generated GRPC stubs for communicating with the admin service. + + This client should be usable regardless of environment in which this is used. In other words, configurations should + be explicit as opposed to inferred from the environment or a configuration file. To create a client, + + .. code-block:: python + + from flytekit.configuration import PlatformConfig + RawSynchronousFlyteClient(PlatformConfig(endpoint="a.b.com", insecure=True)) # or + SynchronousFlyteClient(PlatformConfig(endpoint="a.b.com", insecure=True)) + """ + + _dataproxy_stub: DataProxyServiceStub + + def __init__(self, cfg: PlatformConfig, **kwargs): + """ + Initializes a gRPC channel to the given Flyte Admin service. + + Args: + url: The server address. + insecure: if insecure is desired + """ + self._cfg = cfg + self._channel = wrap_exceptions_channel( + cfg, upgrade_channel_to_authenticated(cfg, upgrade_channel_to_proxy_authenticated(cfg, get_channel(cfg))) + ) + self._stub = _admin_service.AdminServiceStub(self._channel) + self._signal = signal_service.SignalServiceStub(self._channel) + self._dataproxy_stub = dataproxy_service.DataProxyServiceStub(self._channel) + + logger.info( + f"Flyte Client configured -> {self._cfg.endpoint} in {'insecure' if self._cfg.insecure else 'secure'} mode." + ) + # metadata will hold the value of the token to send to the various endpoints. + self._metadata = None + + @classmethod + def with_root_certificate(cls, cfg: PlatformConfig, root_cert_file: str) -> RawSynchronousFlyteClient: + b = None + with open(root_cert_file, "rb") as fp: + b = fp.read() + return RawSynchronousFlyteClient(cfg, credentials=grpc.ssl_channel_credentials(root_certificates=b)) + + @property + def url(self) -> str: + return self._cfg.endpoint + + #################################################################################################################### + # + # Task Endpoints + # + #################################################################################################################### + + def create_task(self, task_create_request): + """ + This will create a task definition in the Admin database. Once successful, the task object can be + retrieved via the client or viewed via the UI or command-line interfaces. + + .. note :: + + Overwrites are not supported so any request for a given project, domain, name, and version that exists in + the database must match the existing definition exactly. This also means that as long as the request + remains identical, calling this method multiple times will result in success. + + :param: flyteidl.admin.task_pb2.TaskCreateRequest task_create_request: The request protobuf object. + :rtype: flyteidl.admin.task_pb2.TaskCreateResponse + :raises flytekit.common.exceptions.user.FlyteEntityAlreadyExistsException: If an identical version of the task + is found, this exception is raised. The client might choose to ignore this exception because the identical + task is already registered. + :raises grpc.RpcError: + """ + return self._stub.CreateTask(task_create_request, metadata=self._metadata) + + def list_task_ids_paginated(self, identifier_list_request): + """ + This returns a page of identifiers for the tasks for a given project and domain. Filters can also be + specified. + + .. note :: + + The name field in the TaskListRequest is ignored. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param: flyteidl.admin.common_pb2.NamedEntityIdentifierListRequest identifier_list_request: + :rtype: flyteidl.admin.common_pb2.NamedEntityIdentifierList + :raises: TODO + """ + return self._stub.ListTaskIds(identifier_list_request, metadata=self._metadata) + + def list_tasks_paginated(self, resource_list_request): + """ + This returns a page of task metadata for tasks in a given project and domain. Optionally, + specifying a name will limit the results to only tasks with that name in the given project and domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param: flyteidl.admin.common_pb2.ResourceListRequest resource_list_request: + :rtype: flyteidl.admin.task_pb2.TaskList + :raises: TODO + """ + return self._stub.ListTasks(resource_list_request, metadata=self._metadata) + + def get_task(self, get_object_request): + """ + This returns a single task for a given identifier. + + :param: flyteidl.admin.common_pb2.ObjectGetRequest get_object_request: + :rtype: flyteidl.admin.task_pb2.Task + :raises: TODO + """ + return self._stub.GetTask(get_object_request, metadata=self._metadata) + + def set_signal(self, signal_set_request: SignalSetRequest) -> SignalSetResponse: + """ + This sets a signal + """ + return self._signal.SetSignal(signal_set_request, metadata=self._metadata) + + def list_signals(self, signal_list_request: SignalListRequest) -> SignalList: + """ + This lists signals + """ + return self._signal.ListSignals(signal_list_request, metadata=self._metadata) + + #################################################################################################################### + # + # Workflow Endpoints + # + #################################################################################################################### + + def create_workflow(self, workflow_create_request): + """ + This will create a workflow definition in the Admin database. Once successful, the workflow object can be + retrieved via the client or viewed via the UI or command-line interfaces. + + .. note :: + + Overwrites are not supported so any request for a given project, domain, name, and version that exists in + the database must match the existing definition exactly. This also means that as long as the request + remains identical, calling this method multiple times will result in success. + + :param: flyteidl.admin.workflow_pb2.WorkflowCreateRequest workflow_create_request: + :rtype: flyteidl.admin.workflow_pb2.WorkflowCreateResponse + :raises flytekit.common.exceptions.user.FlyteEntityAlreadyExistsException: If an identical version of the + workflow is found, this exception is raised. The client might choose to ignore this exception because the + identical workflow is already registered. + :raises grpc.RpcError: + """ + return self._stub.CreateWorkflow(workflow_create_request, metadata=self._metadata) + + def list_workflow_ids_paginated(self, identifier_list_request): + """ + This returns a page of identifiers for the workflows for a given project and domain. Filters can also be + specified. + + .. note :: + + The name field in the WorkflowListRequest is ignored. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param: flyteidl.admin.common_pb2.NamedEntityIdentifierListRequest identifier_list_request: + :rtype: flyteidl.admin.common_pb2.NamedEntityIdentifierList + :raises: TODO + """ + return self._stub.ListWorkflowIds(identifier_list_request, metadata=self._metadata) + + def list_workflows_paginated(self, resource_list_request): + """ + This returns a page of workflow meta-information for workflows in a given project and domain. Optionally, + specifying a name will limit the results to only workflows with that name in the given project and domain. + + .. note :: + + This is a paginated API. Use the token field in the request to specify a page offset token. + The user of the API is responsible for providing this token. + + .. note :: + + If entries are added to the database between requests for different pages, it is possible to receive + entries on the second page that also appeared on the first. + + :param: flyteidl.admin.common_pb2.ResourceListRequest resource_list_request: + :rtype: flyteidl.admin.workflow_pb2.WorkflowList + :raises: TODO + """ + return self._stub.ListWorkflows(resource_list_request, metadata=self._metadata) + + def get_workflow(self, get_object_request): + """ + This returns a single workflow for a given identifier. + + :param: flyteidl.admin.common_pb2.ObjectGetRequest get_object_request: + :rtype: flyteidl.admin.workflow_pb2.Workflow + :raises: TODO + """ + return self._stub.GetWorkflow(get_object_request, metadata=self._metadata) + + #################################################################################################################### + # + # Launch Plan Endpoints + # + #################################################################################################################### + + def create_launch_plan(self, launch_plan_create_request): + """ + This will create a launch plan definition in the Admin database. Once successful, the launch plan object can be + retrieved via the client or viewed via the UI or command-line interfaces. + + .. note :: + + Overwrites are not supported so any request for a given project, domain, name, and version that exists in + the database must match the existing definition exactly. This also means that as long as the request + remains identical, calling this method multiple times will result in success. + + :param: flyteidl.admin.launch_plan_pb2.LaunchPlanCreateRequest launch_plan_create_request: The request + protobuf object + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanCreateResponse + :raises flytekit.common.exceptions.user.FlyteEntityAlreadyExistsException: If an identical version of the + launch plan is found, this exception is raised. The client might choose to ignore this exception because + the identical launch plan is already registered. + :raises grpc.RpcError: + """ + return self._stub.CreateLaunchPlan(launch_plan_create_request, metadata=self._metadata) + + # TODO: List endpoints when they come in + + def get_launch_plan(self, object_get_request): + """ + Retrieves a launch plan entity. + + :param flyteidl.admin.common_pb2.ObjectGetRequest object_get_request: + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlan + """ + return self._stub.GetLaunchPlan(object_get_request, metadata=self._metadata) + + def get_active_launch_plan(self, active_launch_plan_request): + """ + Retrieves a launch plan entity. + + :param flyteidl.admin.common_pb2.ActiveLaunchPlanRequest active_launch_plan_request: + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlan + """ + return self._stub.GetActiveLaunchPlan(active_launch_plan_request, metadata=self._metadata) + + def update_launch_plan(self, update_request): + """ + Allows updates to a launch plan at a given identifier. Currently, a launch plan may only have it's state + switched between ACTIVE and INACTIVE. + + :param flyteidl.admin.launch_plan_pb2.LaunchPlanUpdateRequest update_request: + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanUpdateResponse + """ + return self._stub.UpdateLaunchPlan(update_request, metadata=self._metadata) + + def list_launch_plan_ids_paginated(self, identifier_list_request): + """ + Lists launch plan named identifiers for a given project and domain. + + :param: flyteidl.admin.common_pb2.NamedEntityIdentifierListRequest identifier_list_request: + :rtype: flyteidl.admin.common_pb2.NamedEntityIdentifierList + """ + return self._stub.ListLaunchPlanIds(identifier_list_request, metadata=self._metadata) + + def list_launch_plans_paginated(self, resource_list_request): + """ + Lists Launch Plans for a given Identifier (project, domain, name) + + :param: flyteidl.admin.common_pb2.ResourceListRequest resource_list_request: + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanList + """ + return self._stub.ListLaunchPlans(resource_list_request, metadata=self._metadata) + + def list_active_launch_plans_paginated(self, active_launch_plan_list_request): + """ + Lists Active Launch Plans for a given (project, domain) + + :param: flyteidl.admin.common_pb2.ActiveLaunchPlanListRequest active_launch_plan_list_request: + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanList + """ + return self._stub.ListActiveLaunchPlans(active_launch_plan_list_request, metadata=self._metadata) + + #################################################################################################################### + # + # Named Entity Endpoints + # + #################################################################################################################### + + def update_named_entity(self, update_named_entity_request): + """ + :param flyteidl.admin.common_pb2.NamedEntityUpdateRequest update_named_entity_request: + :rtype: flyteidl.admin.common_pb2.NamedEntityUpdateResponse + """ + return self._stub.UpdateNamedEntity(update_named_entity_request, metadata=self._metadata) + + #################################################################################################################### + # + # Workflow Execution Endpoints + # + #################################################################################################################### + + def create_execution(self, create_execution_request): + """ + This will create an execution for the given execution spec. + :param flyteidl.admin.execution_pb2.ExecutionCreateRequest create_execution_request: + :rtype: flyteidl.admin.execution_pb2.ExecutionCreateResponse + """ + return self._stub.CreateExecution(create_execution_request, metadata=self._metadata) + + def recover_execution(self, recover_execution_request): + """ + This will recreate an execution with the same spec as the one belonging to the given execution identifier. + :param flyteidl.admin.execution_pb2.ExecutionRecoverRequest recover_execution_request: + :rtype: flyteidl.admin.execution_pb2.ExecutionRecoverResponse + """ + return self._stub.RecoverExecution(recover_execution_request, metadata=self._metadata) + + def get_execution(self, get_object_request): + """ + Returns an execution of a workflow entity. + + :param flyteidl.admin.execution_pb2.WorkflowExecutionGetRequest get_object_request: + :rtype: flyteidl.admin.execution_pb2.Execution + """ + return self._stub.GetExecution(get_object_request, metadata=self._metadata) + + def get_execution_data(self, get_execution_data_request): + """ + Returns signed URLs to LiteralMap blobs for an execution's inputs and outputs (when available). + + :param flyteidl.admin.execution_pb2.WorkflowExecutionGetRequest get_execution_data_request: + :rtype: flyteidl.admin.execution_pb2.WorkflowExecutionGetDataResponse + """ + return self._stub.GetExecutionData(get_execution_data_request, metadata=self._metadata) + + def get_execution_metrics(self, get_execution_metrics_request): + """ + Returns metrics partitioning and categorizing the workflow execution time-series. + + :param flyteidl.admin.execution_pb2.WorkflowExecutionGetMetricsRequest get_execution_metrics_request: + :rtype: flyteidl.admin.execution_pb2.WorkflowExecutionGetMetricsResponse + """ + return self._stub.GetExecutionMetrics(get_execution_metrics_request, metadata=self._metadata) + + def list_executions_paginated(self, resource_list_request): + """ + Lists the executions for a given identifier. + + :param flyteidl.admin.common_pb2.ResourceListRequest resource_list_request: + :rtype: flyteidl.admin.execution_pb2.ExecutionList + """ + return self._stub.ListExecutions(resource_list_request, metadata=self._metadata) + + def terminate_execution(self, terminate_execution_request): + """ + :param flyteidl.admin.execution_pb2.TerminateExecutionRequest terminate_execution_request: + :rtype: flyteidl.admin.execution_pb2.TerminateExecutionResponse + """ + return self._stub.TerminateExecution(terminate_execution_request, metadata=self._metadata) + + def relaunch_execution(self, relaunch_execution_request): + """ + :param flyteidl.admin.execution_pb2.ExecutionRelaunchRequest relaunch_execution_request: + :rtype: flyteidl.admin.execution_pb2.ExecutionCreateResponse + """ + return self._stub.RelaunchExecution(relaunch_execution_request, metadata=self._metadata) + + #################################################################################################################### + # + # Node Execution Endpoints + # + #################################################################################################################### + + def get_node_execution(self, node_execution_request): + """ + :param flyteidl.admin.node_execution_pb2.NodeExecutionGetRequest node_execution_request: + :rtype: flyteidl.admin.node_execution_pb2.NodeExecution + """ + return self._stub.GetNodeExecution(node_execution_request, metadata=self._metadata) + + def get_node_execution_data(self, get_node_execution_data_request): + """ + Returns signed URLs to LiteralMap blobs for a node execution's inputs and outputs (when available). + + :param flyteidl.admin.node_execution_pb2.NodeExecutionGetDataRequest get_node_execution_data_request: + :rtype: flyteidl.admin.node_execution_pb2.NodeExecutionGetDataResponse + """ + return self._stub.GetNodeExecutionData(get_node_execution_data_request, metadata=self._metadata) + + def list_node_executions_paginated(self, node_execution_list_request): + """ + :param flyteidl.admin.node_execution_pb2.NodeExecutionListRequest node_execution_list_request: + :rtype: flyteidl.admin.node_execution_pb2.NodeExecutionList + """ + return self._stub.ListNodeExecutions(node_execution_list_request, metadata=self._metadata) + + def list_node_executions_for_task_paginated(self, node_execution_for_task_list_request): + """ + :param flyteidl.admin.node_execution_pb2.NodeExecutionListRequest node_execution_for_task_list_request: + :rtype: flyteidl.admin.node_execution_pb2.NodeExecutionList + """ + return self._stub.ListNodeExecutionsForTask(node_execution_for_task_list_request, metadata=self._metadata) + + #################################################################################################################### + # + # Task Execution Endpoints + # + #################################################################################################################### + + def get_task_execution(self, task_execution_request): + """ + :param flyteidl.admin.task_execution_pb2.TaskExecutionGetRequest task_execution_request: + :rtype: flyteidl.admin.task_execution_pb2.TaskExecution + """ + return self._stub.GetTaskExecution(task_execution_request, metadata=self._metadata) + + def get_task_execution_data(self, get_task_execution_data_request): + """ + Returns signed URLs to LiteralMap blobs for a task execution's inputs and outputs (when available). + + :param flyteidl.admin.task_execution_pb2.TaskExecutionGetDataRequest get_task_execution_data_request: + :rtype: flyteidl.admin.task_execution_pb2.TaskExecutionGetDataResponse + """ + return self._stub.GetTaskExecutionData(get_task_execution_data_request, metadata=self._metadata) + + def list_task_executions_paginated(self, task_execution_list_request): + """ + :param flyteidl.admin.task_execution_pb2.TaskExecutionListRequest task_execution_list_request: + :rtype: flyteidl.admin.task_execution_pb2.TaskExecutionList + """ + return self._stub.ListTaskExecutions(task_execution_list_request, metadata=self._metadata) + + #################################################################################################################### + # + # Project Endpoints + # + #################################################################################################################### + + def list_projects(self, project_list_request: typing.Optional[ProjectListRequest] = None): + """ + This will return a list of the projects registered with the Flyte Admin Service + :param flyteidl.admin.project_pb2.ProjectListRequest project_list_request: + :rtype: flyteidl.admin.project_pb2.Projects + """ + if project_list_request is None: + project_list_request = ProjectListRequest() + return self._stub.ListProjects(project_list_request, metadata=self._metadata) + + def register_project(self, project_register_request): + """ + Registers a project along with a set of domains. + :param flyteidl.admin.project_pb2.ProjectRegisterRequest project_register_request: + :rtype: flyteidl.admin.project_pb2.ProjectRegisterResponse + """ + return self._stub.RegisterProject(project_register_request, metadata=self._metadata) + + def update_project(self, project): + """ + Update an existing project specified by id. + :param flyteidl.admin.project_pb2.Project project: + :rtype: flyteidl.admin.project_pb2.ProjectUpdateResponse + """ + return self._stub.UpdateProject(project, metadata=self._metadata) + + #################################################################################################################### + # + # Matching Attributes Endpoints + # + #################################################################################################################### + def update_project_domain_attributes(self, project_domain_attributes_update_request): + """ + This updates the attributes for a project and domain registered with the Flyte Admin Service + :param flyteidl.admin.ProjectDomainAttributesUpdateRequest project_domain_attributes_update_request: + :rtype: flyteidl.admin.ProjectDomainAttributesUpdateResponse + """ + return self._stub.UpdateProjectDomainAttributes( + project_domain_attributes_update_request, metadata=self._metadata + ) + + def update_workflow_attributes(self, workflow_attributes_update_request): + """ + This updates the attributes for a project, domain, and workflow registered with the Flyte Admin Service + :param flyteidl.admin.UpdateWorkflowAttributesRequest workflow_attributes_update_request: + :rtype: flyteidl.admin.WorkflowAttributesUpdateResponse + """ + return self._stub.UpdateWorkflowAttributes(workflow_attributes_update_request, metadata=self._metadata) + + def get_project_domain_attributes(self, project_domain_attributes_get_request): + """ + This fetches the attributes for a project and domain registered with the Flyte Admin Service + :param flyteidl.admin.ProjectDomainAttributesGetRequest project_domain_attributes_get_request: + :rtype: flyteidl.admin.ProjectDomainAttributesGetResponse + """ + return self._stub.GetProjectDomainAttributes(project_domain_attributes_get_request, metadata=self._metadata) + + def get_workflow_attributes(self, workflow_attributes_get_request): + """ + This fetches the attributes for a project, domain, and workflow registered with the Flyte Admin Service + :param flyteidl.admin.GetWorkflowAttributesAttributesRequest workflow_attributes_get_request: + :rtype: flyteidl.admin.WorkflowAttributesGetResponse + """ + return self._stub.GetWorkflowAttributes(workflow_attributes_get_request, metadata=self._metadata) + + def list_matchable_attributes(self, matchable_attributes_list_request): + """ + This fetches the attributes for a specific resource type registered with the Flyte Admin Service + :param flyteidl.admin.ListMatchableAttributesRequest matchable_attributes_list_request: + :rtype: flyteidl.admin.ListMatchableAttributesResponse + """ + return self._stub.ListMatchableAttributes(matchable_attributes_list_request, metadata=self._metadata) + + #################################################################################################################### + # + # Event Endpoints + # + #################################################################################################################### + + # TODO: (P2) Implement the event endpoints in case there becomes a use-case for third-parties to submit events + # through the client in Python. + + #################################################################################################################### + # + # Data proxy endpoints + # + #################################################################################################################### + def create_upload_location( + self, create_upload_location_request: _dataproxy_pb2.CreateUploadLocationRequest + ) -> _dataproxy_pb2.CreateUploadLocationResponse: + """ + Get a signed url to be used during fast registration + :param flyteidl.service.dataproxy_pb2.CreateUploadLocationRequest create_upload_location_request: + :rtype: flyteidl.service.dataproxy_pb2.CreateUploadLocationResponse + """ + return self._dataproxy_stub.CreateUploadLocation(create_upload_location_request, metadata=self._metadata) + + def create_download_location( + self, create_download_location_request: _dataproxy_pb2.CreateDownloadLocationRequest + ) -> _dataproxy_pb2.CreateDownloadLocationResponse: + return self._dataproxy_stub.CreateDownloadLocation(create_download_location_request, metadata=self._metadata) + + def create_download_link( + self, create_download_link_request: _dataproxy_pb2.CreateDownloadLinkRequest + ) -> _dataproxy_pb2.CreateDownloadLinkResponse: + return self._dataproxy_stub.CreateDownloadLink(create_download_link_request, metadata=self._metadata) diff --git a/flytekit/flytekit/clis/__init__.py b/flytekit/flytekit/clis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/clis/flyte_cli/__init__.py b/flytekit/flytekit/clis/flyte_cli/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/clis/flyte_cli/example.config b/flytekit/flytekit/clis/flyte_cli/example.config new file mode 100644 index 0000000000..cd0bc07223 --- /dev/null +++ b/flytekit/flytekit/clis/flyte_cli/example.config @@ -0,0 +1,11 @@ +# This is an example of what a config file may look like. +# Place this in ~/.flyte/config and flyte-cli will pick it up automatically + +[platform] +url=flyte.company.com +auth=true + +[credentials] +discovery_endpoint=http://corp.idp.com/.well-known/oauth-authorization-server +client_id=123abc123 +redirect_uri=http://localhost:53593/callback diff --git a/flytekit/flytekit/clis/flyte_cli/main.py b/flytekit/flytekit/clis/flyte_cli/main.py new file mode 100644 index 0000000000..75386896ce --- /dev/null +++ b/flytekit/flytekit/clis/flyte_cli/main.py @@ -0,0 +1,2210 @@ +import configparser as _configparser +import importlib as _importlib +import os +import os as _os +import stat as _stat +import sys as _sys +from dataclasses import replace +from typing import Callable, Dict, List, Tuple, Union + +import click as _click +import requests as _requests +from flyteidl.admin import launch_plan_pb2 as _launch_plan_pb2 +from flyteidl.admin import task_pb2 as _task_pb2 +from flyteidl.admin import workflow_pb2 as _workflow_pb2 +from flyteidl.core import identifier_pb2 as _identifier_pb2 +from flyteidl.core import literals_pb2 as _literals_pb2 +from flyteidl.core import tasks_pb2 as _core_tasks_pb2 +from flyteidl.core import workflow_pb2 as _core_workflow_pb2 +from google.protobuf.json_format import MessageToJson +from google.protobuf.pyext.cpp_message import GeneratedProtocolMessageType as _GeneratedProtocolMessageType + +from flytekit import __version__, configuration +from flytekit.clients import friendly as _friendly_client +from flytekit.clis.helpers import hydrate_registration_parameters +from flytekit.core import utils +from flytekit.core.context_manager import FlyteContextManager +from flytekit.exceptions import user as _user_exceptions +from flytekit.interfaces import cli_identifiers +from flytekit.models import common as _common_models +from flytekit.models import filters as _filters +from flytekit.models import launch_plan as _launch_plan +from flytekit.models import literals as _literals +from flytekit.models import named_entity as _named_entity +from flytekit.models.admin import common as _admin_common +from flytekit.models.common import AuthRole as _AuthRole +from flytekit.models.common import RawOutputDataConfig as _RawOutputDataConfig +from flytekit.models.core import execution as _core_execution_models +from flytekit.models.core import identifier as _core_identifier +from flytekit.models.matchable_resource import ClusterResourceAttributes as _ClusterResourceAttributes +from flytekit.models.matchable_resource import ExecutionClusterLabel as _ExecutionClusterLabel +from flytekit.models.matchable_resource import ExecutionQueueAttributes as _ExecutionQueueAttributes +from flytekit.models.matchable_resource import MatchableResource as _MatchableResource +from flytekit.models.matchable_resource import MatchingAttributes as _MatchingAttributes +from flytekit.models.matchable_resource import PluginOverride as _PluginOverride +from flytekit.models.matchable_resource import PluginOverrides as _PluginOverrides +from flytekit.models.project import Project as _Project +from flytekit.models.schedule import Schedule as _Schedule +from flytekit.tools.fast_registration import get_additional_distribution_loc as _get_additional_distribution_loc + +try: # Python 3 + import urllib.parse as _urlparse +except ImportError: # Python 2 + import urlparse as _urlparse + +_tt = str + +# Similar to how kubectl has a config file in the users home directory, this Flyte CLI will also look for one. +# The format of this config file is the same as a workflow's config file, except that the relevant fields are different. +# Please see the example.config file +_default_config_file_dir = ".flyte" +_default_config_file_name = "config" + + +def _welcome_message(): + _click.secho( + "\n################################################################################################################################", + bold=True, + ) + _click.secho( + "# flyte-cli is being deprecated in favor of flytectl. More details about flytectl in https://docs.flyte.org/projects/flytectl/ #", + bold=True, + ) + _click.secho( + "################################################################################################################################\n", + bold=True, + ) + _click.secho("Welcome to Flyte CLI! Version: {}\n".format(_tt(__version__)), bold=True) + + +def _get_user_filepath_home(): + return _os.path.expanduser("~") + + +def _get_config_file_path(): + home = _get_user_filepath_home() + return _os.path.join(home, _default_config_file_dir, _default_config_file_name) + + +def _detect_default_config_file(): + config_file = _get_config_file_path() + if _get_user_filepath_home() and _os.path.exists(config_file): + _click.secho("Using default config file at {}".format(_tt(config_file)), fg="blue") + return config_file + else: + _click.secho( + """Config file not found at default location, relying on environment variables instead. + To setup your config file run 'flyte-cli setup-config'""", + fg="blue", + ) + return None + + +def _get_io_string(literal_map, verbose=False): + """ + :param flytekit.models.literals.LiteralMap literal_map: + :param bool verbose: + :rtype: Text + """ + return str(literal_map) + + +def _fetch_and_stringify_literal_map(path, verbose=False): + """ + :param Text path: + :param bool verbose: + :rtype: Text + """ + ctx = FlyteContextManager.current_context() + with utils.AutoDeletingTempDir("flytecli") as tmp: + try: + fname = tmp.get_named_tempfile("literalmap.pb") + ctx.file_access.get_data(path, fname) + literal_map = _literals.LiteralMap.from_flyte_idl( + utils.load_proto_from_file(_literals_pb2.LiteralMap, fname) + ) + return _get_io_string(literal_map, verbose=verbose) + except Exception: + return "Failed to pull data from {}. Do you have permissions?".format(path) + + +def _prefix_lines(prefix, txt): + """ + :param Text prefix: + :param Text txt: + :rtype: Text + """ + return "\n{}".format(prefix).join(txt.splitlines()) + + +def _secho_workflow_status(status, nl=True): + red_phases = { + _core_execution_models.WorkflowExecutionPhase.FAILED, + _core_execution_models.WorkflowExecutionPhase.ABORTED, + _core_execution_models.WorkflowExecutionPhase.FAILING, + _core_execution_models.WorkflowExecutionPhase.TIMED_OUT, + } + yellow_phases = { + _core_execution_models.WorkflowExecutionPhase.QUEUED, + _core_execution_models.WorkflowExecutionPhase.UNDEFINED, + } + green_phases = { + _core_execution_models.WorkflowExecutionPhase.SUCCEEDED, + _core_execution_models.WorkflowExecutionPhase.SUCCEEDING, + } + if status in red_phases: + fg = "red" + elif status in yellow_phases: + fg = "yellow" + elif status in green_phases: + fg = "green" + else: + fg = "blue" + + _click.secho( + "{:10} ".format(_tt(_core_execution_models.WorkflowExecutionPhase.enum_to_string(status))), + bold=True, + fg=fg, + nl=nl, + ) + + +def _secho_node_execution_status(status, nl=True): + red_phases = { + _core_execution_models.NodeExecutionPhase.FAILING, + _core_execution_models.NodeExecutionPhase.FAILED, + _core_execution_models.NodeExecutionPhase.ABORTED, + _core_execution_models.NodeExecutionPhase.TIMED_OUT, + } + yellow_phases = { + _core_execution_models.NodeExecutionPhase.QUEUED, + _core_execution_models.NodeExecutionPhase.UNDEFINED, + } + green_phases = {_core_execution_models.NodeExecutionPhase.SUCCEEDED} + if status in red_phases: + fg = "red" + elif status in yellow_phases: + fg = "yellow" + elif status in green_phases: + fg = "green" + else: + fg = "blue" + + _click.secho( + "{:10} ".format(_tt(_core_execution_models.NodeExecutionPhase.enum_to_string(status))), + bold=True, + fg=fg, + nl=nl, + ) + + +def _secho_task_execution_status(status, nl=True): + red_phases = { + _core_execution_models.TaskExecutionPhase.ABORTED, + _core_execution_models.TaskExecutionPhase.FAILED, + } + yellow_phases = { + _core_execution_models.TaskExecutionPhase.QUEUED, + _core_execution_models.TaskExecutionPhase.UNDEFINED, + _core_execution_models.TaskExecutionPhase.RUNNING, + } + green_phases = {_core_execution_models.TaskExecutionPhase.SUCCEEDED} + if status in red_phases: + fg = "red" + elif status in yellow_phases: + fg = "yellow" + elif status in green_phases: + fg = "green" + else: + fg = "blue" + + _click.secho( + "{:10} ".format(_tt(_core_execution_models.TaskExecutionPhase.enum_to_string(status))), + bold=True, + fg=fg, + nl=nl, + ) + + +def _secho_one_execution(ex, urns_only): + if not urns_only: + _click.echo( + "{:100} {:40} {:40}".format( + _tt(cli_identifiers.WorkflowExecutionIdentifier.promote_from_model(ex.id)), + _tt(ex.id.name), + _tt(ex.spec.launch_plan.name), + ), + nl=False, + ) + _secho_workflow_status(ex.closure.phase) + else: + _click.echo( + "{:100}".format(_tt(cli_identifiers.WorkflowExecutionIdentifier.promote_from_model(ex.id))), + nl=True, + ) + + +def _terminate_one_execution(client, urn, cause, shouldPrint=True): + if shouldPrint: + _click.echo("{:100} {:40}".format(_tt(urn), _tt(cause))) + client.terminate_execution(cli_identifiers.WorkflowExecutionIdentifier.from_python_std(urn), cause) + + +def _update_one_launch_plan(client: _friendly_client.SynchronousFlyteClient, urn, state): + if state == "active": + state = _launch_plan.LaunchPlanState.ACTIVE + else: + state = _launch_plan.LaunchPlanState.INACTIVE + client.update_launch_plan(cli_identifiers.Identifier.from_python_std(urn), state) + _click.echo("Successfully updated {}".format(_tt(urn))) + + +def _render_schedule_expr(lp): + sched_expr = "NONE" + if lp.spec.entity_metadata.schedule and lp.spec.entity_metadata.schedule.cron_expression: + sched_expr = "cron({cron_expr})".format(cron_expr=_tt(lp.spec.entity_metadata.schedule.cron_expression)) + elif lp.spec.entity_metadata.schedule and lp.spec.entity_metadata.schedule.rate: + sched_expr = "rate({unit}={value})".format( + unit=_tt(_Schedule.FixedRateUnit.enum_to_string(lp.spec.entity_metadata.schedule.rate.unit)), + value=_tt(lp.spec.entity_metadata.schedule.rate.value), + ) + return "{:30}".format(sched_expr) + + +def _get_client(host: str, insecure: bool) -> _friendly_client.SynchronousFlyteClient: + parent_ctx = _click.get_current_context(silent=True) + kwargs = {} + if parent_ctx.obj["cacert"]: + kwargs["root_certificates"] = parent_ctx.obj["cacert"] + cfg = parent_ctx.obj["config"] + cfg = replace(cfg, endpoint=host, insecure=insecure) + + return _friendly_client.SynchronousFlyteClient(cfg, **kwargs) + + +_PROJECT_FLAGS = ["-p", "--project"] +_DOMAIN_FLAGS = ["-d", "--domain"] +_NAME_FLAGS = ["-n", "--name"] +_VERSION_FLAGS = ["-v", "--version"] +_HOST_FLAGS = ["-h", "--host"] +_CONFIG_FLAGS = ["-c", "--config"] +_PRINCIPAL_FLAGS = ["-r", "--principal"] +_INSECURE_FLAGS = ["-i", "--insecure"] +_CERT_FLAGS = ["--cacert"] + +_project_option = _click.option(*_PROJECT_FLAGS, required=True, help="The project namespace to query.") +_optional_project_option = _click.option( + *_PROJECT_FLAGS, + required=False, + default=None, + help="[Optional] The project namespace to query.", +) +_domain_option = _click.option(*_DOMAIN_FLAGS, required=True, help="The domain namespace to query.") +_optional_domain_option = _click.option( + *_DOMAIN_FLAGS, + required=False, + default=None, + help="[Optional] The domain namespace to query.", +) +_name_option = _click.option(*_NAME_FLAGS, required=True, help="The name to query.") +_optional_name_option = _click.option( + *_NAME_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] The name to query.", +) +_principal_option = _click.option(*_PRINCIPAL_FLAGS, required=True, help="Your team name, or your name") +_optional_principal_option = _click.option( + *_PRINCIPAL_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] Your team name, or your name", +) +_insecure_option = _click.option(*_INSECURE_FLAGS, is_flag=True, help="Do not use SSL") +_urn_option = _click.option("-u", "--urn", required=True, help="The unique identifier for an entity.") +_optional_urn_option = _click.option("-u", "--urn", required=False, help="The unique identifier for an entity.") + +_host_option = _click.option( + *_HOST_FLAGS, + required=False, + help="The URL for the Flyte Admin Service. If you intend for this to be consistent, set the FLYTE_PLATFORM_URL " + "environment variable to the desired URL and this will not need to be set.", +) +_token_option = _click.option( + "-t", + "--token", + required=False, + default="", + type=str, + help="Pagination token from which to start listing in the list of results.", +) +_limit_option = _click.option( + "-l", + "--limit", + required=False, + default=100, + type=int, + help="Maximum number of results to return for this call.", +) +_show_all_option = _click.option( + "-a", + "--show-all", + is_flag=True, + default=False, + help="Set this flag to page through and list all results.", +) +# TODO: Provide documentation on filter format +_filter_option = _click.option( + "-f", + "--filter", + multiple=True, + help="""Filter to be applied. Multiple filters can be applied and they will be ANDed together. + Filters may be supplied as strings such as 'eq(name, workflow_name)'. Additional documentation on filter + syntax can be found here: https://docs.flyte.org/en/latest/dive_deep/admin_service.html#adding-request-filters""", +) +_state_choice = _click.option( + "--state", + type=_click.Choice(["active", "inactive"]), + required=True, + help="Whether or not to set schedule as active.", +) +_named_entity_state_choice = _click.option( + "--state", + type=_click.Choice(["active", "archived"]), + required=True, + help="The state change to apply to a named entity", +) +_named_entity_description_option = _click.option( + "--description", + required=False, + type=str, + help="Concise description for the entity.", +) +_sort_by_option = _click.option( + "--sort-by", + required=False, + help="Provide an entity field to be sorted. i.e. asc(name) or desc(name)", +) +_show_io_option = _click.option( + "--show-io", + is_flag=True, + default=False, + help="Set this flag to view inputs and outputs. Pair with the --verbose flag to get the full textual description" + " inputs and outputs.", +) +_verbose_option = _click.option( + "--verbose", + is_flag=True, + default=False, + help="Set this flag to view the full textual description of all fields.", +) + +_filename_option = _click.option("-f", "--filename", required=True, help="File path of pb file") +_idl_class_option = _click.option( + "-p", + "--proto_class", + required=True, + help="Dot (.) separated path to Python IDL class. (e.g. flyteidl.core.workflow_closure_pb2.WorkflowClosure)", +) +_cause_option = _click.option( + "-c", + "--cause", + required=True, + help="The message signaling the cause of the termination of the execution(s)", +) +_optional_urns_only_option = _click.option( + "--urns-only", + is_flag=True, + default=False, + required=False, + help="[Optional] Set the flag if you want to output the urn(s) only. Setting this will override the verbose flag", +) +_project_identifier_option = _click.option( + "-p", + "--identifier", + required=True, + type=str, + help="Unique identifier for the project.", +) +_project_name_option = _click.option( + "-n", + "--name", + required=True, + type=str, + help="The human-readable name for the project.", +) +_project_description_option = _click.option( + "-d", + "--description", + required=True, + type=str, + help="Concise description for the project.", +) +_watch_option = _click.option( + "-w", + "--watch", + is_flag=True, + default=False, + help="Set the flag if you want the command to keep watching the execution until its completion", +) + +_assumable_iam_role_option = _click.option( + "--assumable-iam-role", help="Custom assumable iam auth role to register launch plans with" +) +_kubernetes_service_acct_option = _click.option( + "-s", + "--kubernetes-service-account", + help="Custom kubernetes service account auth role to register launch plans with", +) +_output_location_prefix_option = _click.option( + "-o", "--output-location-prefix", help="Custom output location prefix for offloaded types (files/schemas)" +) +_files_argument = _click.argument( + "files", + type=_click.Path(exists=True), + nargs=-1, +) + + +class _FlyteSubCommand(_click.Command): + _PASSABLE_ARGS = { + "project": _PROJECT_FLAGS[0], + "domain": _DOMAIN_FLAGS[0], + "name": _NAME_FLAGS[0], + "host": _HOST_FLAGS[0], + "config": _CONFIG_FLAGS[0], + } + + _PASSABLE_FLAGS = { + "insecure": _INSECURE_FLAGS[0], + } + + def make_context(self, cmd_name, args, parent=None): + # This list represents the set of args/flags that can be, and are, set on both the parent and subcommand. + prefix_args = [] + for param in self.params: + if ( + param.name in type(self)._PASSABLE_ARGS + and param.name in parent.params + and parent.params[param.name] is not None + ): + prefix_args.extend([type(self)._PASSABLE_ARGS[param.name], str(parent.params[param.name])]) + + # For flags, we don't append the value of the flag, otherwise click will fail to parse + if param.name in type(self)._PASSABLE_FLAGS and param.name in parent.params and parent.params[param.name]: + prefix_args.append(type(self)._PASSABLE_FLAGS[param.name]) + + # Pass the parameters to the subcommand "setup-config", so both of the below commands can work. + # flyte-cli -h localhost:30081 -i setup-config + # flyte-cli setup-config -h localhost:30081 -i + ctx = super(_FlyteSubCommand, self).make_context(cmd_name, prefix_args + args, parent=parent) + ctx.obj = ctx.obj or {} + ctx.obj["cacert"] = parent.params["cacert"] or None + if cmd_name == "setup-config": + return ctx + + config_file = parent.params["config"] + if config_file is None: + # Run this as the module is loading to pick up settings that click can + # then use when constructing the commands + config_file = _detect_default_config_file() + + config_obj = configuration.PlatformConfig.auto(config_file=config_file) + + # These two flags are special in that they are specifiable in both the user's default ~/.flyte/config file, + # and in the flyte-cli command itself, both in the parent-command position (flyte-cli) , and in the + # child-command position (e.g. list-task-names). + # For both host and insecure, command line values will override the setting in a config file. + # + # The host url option is a required setting, so if missing it will fail, but it may be set in the click command, + # so we don't have to check now. It will be checked later. + + # If the host was not specified in the parent command, and also not in the subcommand, but is in the file, + # then add in the switch and value before creating the context + if _HOST_FLAGS[0] not in prefix_args and _HOST_FLAGS[0] not in args and config_obj.endpoint: + prefix_args.extend([_HOST_FLAGS[0], config_obj.endpoint]) + + # If insecure was not in the parent command and not in the subcommand, but is true in the config object (the + # default is False), then add the flag to the args before creating the context. + if _INSECURE_FLAGS[0] not in prefix_args and _INSECURE_FLAGS[0] not in args and config_obj.insecure: + prefix_args.append(_INSECURE_FLAGS[0]) + + # Create context object and fill in with additional objects + ctx = super(_FlyteSubCommand, self).make_context(cmd_name, prefix_args + args, parent=parent) + ctx.obj = ctx.obj or {} + ctx.obj["cacert"] = parent.params["cacert"] or None + ctx.obj["config"] = config_obj + + return ctx + + +@_click.option( + *_CONFIG_FLAGS, + required=False, + type=_click.Path(exists=True), + default=None, + help="[Optional] The filepath to the config file to pass to the sub-command (if applicable)." + " If set again in the sub-command, the sub-command's parameter takes precedence.", +) +@_click.option( + *_HOST_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] The host to pass to the sub-command (if applicable). If set again in the sub-command, " + "the sub-command's parameter takes precedence.", +) +@_click.option( + *_PROJECT_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] The project to pass to the sub-command (if applicable) If set again in the sub-command, " + "the sub-command's parameter takes precedence.", +) +@_click.option( + *_DOMAIN_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] The domain to pass to the sub-command (if applicable) If set again in the sub-command, " + "the sub-command's parameter takes precedence.", +) +@_click.option( + *_NAME_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] The name to pass to the sub-command (if applicable) If set again in the sub-command, " + "the sub-command's parameter takes precedence.", +) +@_click.option( + *_CERT_FLAGS, + required=False, + type=str, + default=None, + help="[Optional] Path to certificate file to be used to do establish SSL connection with Admin", +) +@_insecure_option +@_click.group("flyte-cli", deprecated=True) +@_click.pass_context +def _flyte_cli(ctx, host, config, project, domain, name, cacert, insecure): + """ + Command line tool for interacting with all entities on the Flyte Platform. + """ + if cacert and insecure: + raise _user_exceptions.FlyteValidationException("Should not pass both certificate and insecure options!") + + +######################################################################################################################## +# +# Miscellaneous Commands +# +######################################################################################################################## + + +@_flyte_cli.command("parse-proto", cls=_click.Command) +@_filename_option +@_idl_class_option +def parse_proto(filename, proto_class): + _welcome_message() + split = proto_class.split(".") + idl_module = ".".join(split[:-1]) + idl_obj = split[-1] + mod = _importlib.import_module(idl_module) + idl = getattr(mod, idl_obj) + obj = utils.load_proto_from_file(idl, filename) + + jsonObj = MessageToJson(obj) + + _click.echo(jsonObj) + _click.echo("") + + +######################################################################################################################## +# +# Task Commands +# +######################################################################################################################## + + +@_flyte_cli.command("list-task-names", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_sort_by_option +def list_task_names(project, domain, host, insecure, token, limit, show_all, sort_by): + """ + List the name of the tasks that are in the registered workflow under + a specific project and domain. + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Task Names Found in {}:{}\n".format(_tt(project), _tt(domain))) + while True: + task_ids, next_token = client.list_task_ids_paginated( + project, + domain, + limit=limit, + token=token, + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for t in task_ids: + _click.echo("\t{}".format(_tt(t.name))) + + if show_all is not True: + if next_token: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + _click.echo("") + + +@_flyte_cli.command("list-task-versions", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_optional_name_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_filter_option +@_sort_by_option +def list_task_versions(project, domain, name, host, insecure, token, limit, show_all, filter, sort_by): + """ + List all the versions of the tasks under a specific {Project, Domain} tuple. + If the name of a certain task is supplied, this command will list all the + versions of that particular task (identifiable by {Project, Domain, Name}). + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Task Versions Found for {}:{}:{}\n".format(_tt(project), _tt(domain), _tt(name or "*"))) + _click.echo("{:50} {:40}".format("Version", "Urn")) + while True: + task_list, next_token = client.list_tasks_paginated( + _common_models.NamedEntityIdentifier(project, domain, name), + limit=limit, + token=token, + filters=[_filters.Filter.from_python_std(f) for f in filter], + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for t in task_list: + _click.echo( + "{:50} {:40}".format( + _tt(t.id.version), + _tt(cli_identifiers.Identifier.promote_from_model(t.id)), + ) + ) + + if show_all is not True: + if next_token: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + _click.echo("") + + +@_flyte_cli.command("get-task", cls=_FlyteSubCommand) +@_urn_option +@_host_option +@_insecure_option +def get_task(urn, host, insecure): + """ + Get the details of a certain version of a task identified by the URN of it. + The URN of the versioned task is in the form of ``tsk::::``. + """ + _welcome_message() + client = _get_client(host, insecure) + t = client.get_task(cli_identifiers.Identifier.from_python_std(urn)) + _click.echo(_tt(t)) + _click.echo("") + + +######################################################################################################################## +# +# Workflow Commands +# +######################################################################################################################## + + +@_flyte_cli.command("list-workflow-names", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_sort_by_option +def list_workflow_names(project, domain, host, insecure, token, limit, show_all, sort_by): + """ + List the names of the workflows under a scope specified by ``{project, domain}``. + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Workflow Names Found in {}:{}\n".format(_tt(project), _tt(domain))) + while True: + wf_ids, next_token = client.list_workflow_ids_paginated( + project, + domain, + limit=limit, + token=token, + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for i in wf_ids: + _click.echo("\t{}".format(_tt(i.name))) + + if show_all is not True: + if next_token: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + _click.echo("") + + +@_flyte_cli.command("list-workflow-versions", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_optional_name_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_filter_option +@_sort_by_option +def list_workflow_versions(project, domain, name, host, insecure, token, limit, show_all, filter, sort_by): + """ + List all the versions of the workflows under the scope specified by ``{project, domain}``. + If the name of a a certain workflow is supplied, this command will list all the + versions of that particular workflow (identifiable by ``{project, domain, name}``). + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Workflow Versions Found for {}:{}:{}\n".format(_tt(project), _tt(domain), _tt(name or "*"))) + _click.echo("{:50} {:40}".format("Version", "Urn")) + while True: + wf_list, next_token = client.list_workflows_paginated( + _common_models.NamedEntityIdentifier(project, domain, name), + limit=limit, + token=token, + filters=[_filters.Filter.from_python_std(f) for f in filter], + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for w in wf_list: + _click.echo( + "{:50} {:40}".format( + _tt(w.id.version), + _tt(cli_identifiers.Identifier.promote_from_model(w.id)), + ) + ) + + if show_all is not True: + if next_token: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + _click.echo("") + + +@_flyte_cli.command("get-workflow", cls=_FlyteSubCommand) +@_urn_option +@_host_option +@_insecure_option +def get_workflow(urn, host, insecure): + """ + Get the details of a certain version of a workflow identified by the URN in the form of + ``wf::::`` + """ + _welcome_message() + client = _get_client(host, insecure) + _click.echo(client.get_workflow(cli_identifiers.Identifier.from_python_std(urn))) + # TODO: Print workflow pretty + _click.echo("") + + +######################################################################################################################## +# +# Launch Plan Commands +# +######################################################################################################################## + + +@_flyte_cli.command("list-launch-plan-names", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_sort_by_option +def list_launch_plan_names(project, domain, host, insecure, token, limit, show_all, sort_by): + """ + List the names of the launch plans under the scope specified by {project, domain}. + """ + _welcome_message() + client = _get_client(host, insecure) + _click.echo("Launch Plan Names Found in {}:{}\n".format(_tt(project), _tt(domain))) + while True: + wf_ids, next_token = client.list_launch_plan_ids_paginated( + project, + domain, + limit=limit, + token=token, + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for i in wf_ids: + _click.echo("\t{}".format(_tt(i.name))) + + if show_all is not True: + if next_token: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + _click.echo("") + + +@_flyte_cli.command("list-active-launch-plans", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_sort_by_option +@_optional_urns_only_option +def list_active_launch_plans(project, domain, host, insecure, token, limit, show_all, sort_by, urns_only): + """ + List the information of all the active launch plans under the scope specified by {project, domain}. + An active launch plan is a launch plan with an active schedule associated with it. + """ + if not urns_only: + _welcome_message() + _click.echo("Active Launch Plan Found in {}:{}\n".format(_tt(project), _tt(domain))) + _click.echo("{:30} {:50} {:80}".format("Schedule", "Version", "Urn")) + + client = _get_client(host, insecure) + while True: + active_lps, next_token = client.list_active_launch_plans_paginated( + project, + domain, + limit=limit, + token=token, + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + + for lp in active_lps: + if urns_only: + _click.echo("{:80}".format(_tt(cli_identifiers.Identifier.promote_from_model(lp.id)))) + else: + _click.echo( + "{:30} {:50} {:80}".format( + _render_schedule_expr(lp), + _tt(lp.id.version), + _tt(cli_identifiers.Identifier.promote_from_model(lp.id)), + ), + ) + + if show_all is not True: + if next_token and not urns_only: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + + if not urns_only: + _click.echo("") + return + + +@_flyte_cli.command("list-launch-plan-versions", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_optional_name_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_filter_option +@_sort_by_option +@_optional_urns_only_option +def list_launch_plan_versions( + project, + domain, + name, + host, + insecure, + token, + limit, + show_all, + filter, + sort_by, + urns_only, +): + """ + List the versions of all the launch plans under the scope specified by {project, domain}. + """ + if not urns_only: + _welcome_message() + _click.echo("Launch Plan Versions Found for {}:{}:{}\n".format(_tt(project), _tt(domain), _tt(name))) + _click.echo("{:50} {:80} {:30} {:15}".format("Version", "Urn", "Schedule", "Schedule State")) + + client = _get_client(host, insecure) + while True: + lp_list, next_token = client.list_launch_plans_paginated( + _common_models.NamedEntityIdentifier(project, domain, name), + limit=limit, + token=token, + filters=[_filters.Filter.from_python_std(f) for f in filter], + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for l in lp_list: + if urns_only: + _click.echo(_tt(cli_identifiers.Identifier.promote_from_model(l.id))) + else: + _click.echo( + "{:50} {:80} ".format( + _tt(l.id.version), + _tt(cli_identifiers.Identifier.promote_from_model(l.id)), + ), + nl=False, + ) + if l.spec.entity_metadata.schedule is not None and ( + l.spec.entity_metadata.schedule.cron_expression or l.spec.entity_metadata.schedule.rate + ): + _click.echo("{:30} ".format(_render_schedule_expr(l)), nl=False) + _click.secho( + _launch_plan.LaunchPlanState.enum_to_string(l.closure.state), + fg="green" if l.closure.state == _launch_plan.LaunchPlanState.ACTIVE else None, + ) + else: + _click.echo() + + if show_all is not True: + if next_token and not urns_only: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + if not urns_only: + _click.echo("") + + +@_flyte_cli.command("get-launch-plan", cls=_FlyteSubCommand) +@_urn_option +@_host_option +@_insecure_option +def get_launch_plan(urn, host, insecure): + """ + Get the details of a certain launch plan identified by the URN of that launch plan. + The URN of a launch plan is in the form of ``lp::::`` + """ + _welcome_message() + client = _get_client(host, insecure) + _click.echo(_tt(client.get_launch_plan(cli_identifiers.Identifier.from_python_std(urn)))) + # TODO: Print launch plan pretty + _click.echo("") + + +@_flyte_cli.command("get-active-launch-plan", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_name_option +@_host_option +@_insecure_option +def get_active_launch_plan(project, domain, name, host, insecure): + """ + List the versions of all the launch plans under the scope specified by {project, domain}. + """ + _welcome_message() + client = _get_client(host, insecure) + + lp = client.get_active_launch_plan(_common_models.NamedEntityIdentifier(project, domain, name)) + _click.echo("Active Launch Plan for {}:{}:{}\n".format(_tt(project), _tt(domain), _tt(name))) + _click.echo(lp) + _click.echo("") + + +@_flyte_cli.command("update-launch-plan", cls=_FlyteSubCommand) +@_state_choice +@_host_option +@_insecure_option +@_optional_urn_option +def update_launch_plan(state, host, insecure, urn=None): + _welcome_message() + client = _get_client(host, insecure) + + if urn is None: + try: + # Examine whether the input is from the named pipe + if _stat.S_ISFIFO(_os.fstat(0).st_mode): + for line in _sys.stdin.readlines(): + _update_one_launch_plan(client, urn=line.rstrip(), state=state) + else: + # If the commandline parameter urn is not supplied, and neither + # the input comes from a pipe, it means the user is not using + # this command appropriately + raise _click.UsageError('Missing option "-u" / "--urn" or missing pipe inputs') + except KeyboardInterrupt: + _sys.stdout.flush() + else: + _update_one_launch_plan(client, urn=urn, state=state) + + +######################################################################################################################## +# +# Execution Commands +# +######################################################################################################################## + + +@_flyte_cli.command("recover-execution", cls=_FlyteSubCommand) +@_urn_option +@_optional_name_option +@_host_option +@_insecure_option +def recover_execution(urn, name, host, insecure): + """ + Recreates a previously-run workflow execution that will only start executing from the last known failure point. + + In Recover mode, users cannot change any input parameters or update the version of the execution. + This is extremely useful to recover from system errors and byzantine faults like + - loss of K8s cluster + - bugs in platform or instability + - machine failures + - downstream system failures (downstream services) + - or simply to recover executions that failed because of retry exhaustion and should complete if tried again. + + You can optionally assign a name to the recreated execution you trigger or let the system assign one. + + Usage: + $ flyte-cli recover-execution -u ex:flyteexamples:development:some-workflow:abc123 -n my_retry_name + + These arguments are then collected, and passed into the `lp_args` variable as a Tuple[Text]. + Users should use the get-execution and get-launch-plan commands to ascertain the names of inputs to use. + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Recovering execution {}\n".format(_tt(urn))) + + original_workflow_execution_identifier = cli_identifiers.WorkflowExecutionIdentifier.from_python_std(urn) + + execution_identifier_resp = client.recover_execution(id=original_workflow_execution_identifier, name=name) + execution_identifier = cli_identifiers.WorkflowExecutionIdentifier.promote_from_model(execution_identifier_resp) + _click.secho("Launched execution: {}".format(execution_identifier), fg="blue") + _click.echo("") + + +@_flyte_cli.command("terminate-execution", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_cause_option +@_optional_urn_option +def terminate_execution(host, insecure, cause, urn=None): + """ + Terminate an execution or a list of executions. This command terminates an execution + specified by the URN. It can only terminate the executions the status of which are "RUNNING". + The post-termination status of those executions will become "ABORTED". + When terminating an execution, the cause of termination is a required input. + + This command also supports batch terminating multiple executions at a time, which can be + achieved by supplying multiple URNs via the named pipe. + + Note that, the termination of executions might not take immediate effect, as the + FlyteCLI only sends a termination request to FlyteAdmin. The actual termination + of the execution(s) depends on many other factors such as the status of the + machine serving the execution, etc. + + e.g., + $ flyte-cli -h localhost:30081 -p flyteexamples -d development terminate-execution \ + -u lp:flyteexamples:development:some-execution:abc123 + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Killing the following executions:\n") + _click.echo("{:100} {:40}".format("Urn", "Cause")) + + # It first collects the urns in a list, and then send terminate request + # for them one-by-one + if urn is None: + try: + # Examine whether the input is from FIFO (named pipe) + if _stat.S_ISFIFO(_os.fstat(0).st_mode): + for line in _sys.stdin.readlines(): + _terminate_one_execution(client, line.rstrip(), cause) + else: + # If the commandline parameter urn is not supplied, and neither + # the input is from a pipe, it means the user is not using + # this command appropriately + raise _click.UsageError('Missing option "-u" / "--urn" or missing pipe inputs.') + except KeyboardInterrupt: + _sys.stdout.flush() + else: + _terminate_one_execution(client, urn, cause) + + +@_flyte_cli.command("list-executions", cls=_FlyteSubCommand) +@_project_option +@_domain_option +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_filter_option +@_sort_by_option +@_optional_urns_only_option +def list_executions(project, domain, host, insecure, token, limit, show_all, filter, sort_by, urns_only): + """ + List the key information of all the executions under the scope specified by {project, domain}. + Users can supply additional filter arguments to show only the desired executions. + + Note that, when the ``--urns-only`` flag is not set, this command prints out the complete tabular + output with key pieces of information such as the URN, the Name and the Status of the executions; + the column headers are also printed. If the flag is set, on the other hand, only the URNs + of the executions will be printed. This will come in handy when the user wants to, for example, terminate all the + running executions at once. + """ + if not urns_only: + _welcome_message() + _click.echo("Executions Found in {}:{}\n".format(_tt(project), _tt(domain))) + _click.echo("{:100} {:40} {:10}".format("Urn", "Name", "Status")) + + client = _get_client(host, insecure) + + while True: + exec_ids, next_token = client.list_executions_paginated( + project, + domain, + limit=limit, + token=token, + filters=[_filters.Filter.from_python_std(f) for f in filter], + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for ex in exec_ids: + _secho_one_execution(ex, urns_only) + + if show_all is not True: + if next_token and not urns_only: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + if not urns_only: + _click.echo("") + + +def _get_io(node_executions, wf_execution, show_io, verbose): + # Fetch I/O if necessary + uri_to_message_map = {} + if show_io: + uris = [ne.input_uri for ne in node_executions] + uris.extend([ne.closure.output_uri for ne in node_executions if ne.closure.output_uri is not None]) + if ( + wf_execution is not None + and wf_execution.closure.outputs is not None + and wf_execution.closure.outputs.uri is not None + ): + uris.append(wf_execution.closure.outputs.uri) + + with _click.progressbar(uris, label="Downloading Inputs and Outputs") as progress_bar_uris: + for uri in progress_bar_uris: + uri_to_message_map[uri] = _fetch_and_stringify_literal_map(uri, verbose=verbose) + return uri_to_message_map + + +def _render_workflow_execution(wf_execution, uri_to_message_map, show_io, verbose): + _click.echo( + "\nExecution {project}:{domain}:{name}\n".format( + project=_tt(wf_execution.id.project), + domain=_tt(wf_execution.id.domain), + name=_tt(wf_execution.id.name), + ) + ) + _click.echo("\t{:15} ".format("State:"), nl=False) + _secho_workflow_status(wf_execution.closure.phase) + _click.echo( + "\t{:15} {}".format( + "Launch Plan:", + _tt(cli_identifiers.Identifier.promote_from_model(wf_execution.spec.launch_plan)), + ) + ) + + if show_io: + _click.secho( + "\tInputs: {}\n".format( + _prefix_lines( + "\t\t", + _get_io_string(wf_execution.closure.computed_inputs, verbose=verbose), + ) + ) + ) + if wf_execution.closure.outputs is not None: + if wf_execution.closure.outputs.uri: + _click.secho( + "\tOutputs: {}\n".format( + _prefix_lines( + "\t\t", + uri_to_message_map.get( + wf_execution.closure.outputs.uri, + wf_execution.closure.outputs.uri, + ), + ) + ) + ) + elif wf_execution.closure.outputs.values is not None: + _click.secho( + "\tOutputs: {}\n".format( + _prefix_lines( + "\t\t", + _get_io_string(wf_execution.closure.outputs.values, verbose=verbose), + ) + ) + ) + else: + _click.echo("\t{:15} (None)".format("Outputs:")) + + if wf_execution.closure.error is not None: + _click.secho( + _prefix_lines("\t", _render_error(wf_execution.closure.error)), + fg="red", + bold=True, + ) + + +def _render_error(error): + out = "Error:\n" + out += "\tCode: {}\n".format(error.code) + out += "\tMessage:\n" + for l in error.message.splitlines(): + out += "\t\t{}".format(_tt(l)) + return out + + +def _get_all_task_executions_for_node(client, node_execution_identifier): + fetched_task_execs = [] + token = "" + while True: + num_to_fetch = 100 + task_execs, next_token = client.list_task_executions_paginated( + node_execution_identifier=node_execution_identifier, + limit=num_to_fetch, + token=token, + ) + for te in task_execs: + fetched_task_execs.append(te) + + if not next_token: + break + token = next_token + + return fetched_task_execs + + +def _get_all_node_executions(client, workflow_execution_identifier=None, task_execution_identifier=None): + all_node_execs = [] + token = "" + while True: + num_to_fetch = 100 + if workflow_execution_identifier: + node_execs, next_token = client.list_node_executions( + workflow_execution_identifier=workflow_execution_identifier, + limit=num_to_fetch, + token=token, + ) + else: + node_execs, next_token = client.list_node_executions_for_task_paginated( + task_execution_identifier=task_execution_identifier, + limit=num_to_fetch, + token=token, + ) + all_node_execs.extend(node_execs) + if not next_token: + break + token = next_token + return all_node_execs + + +def _render_node_executions(client, node_execs, show_io, verbose, host, insecure, wf_execution=None): + node_executions_to_task_executions = {} + for node_exec in node_execs: + node_executions_to_task_executions[node_exec.id] = _get_all_task_executions_for_node(client, node_exec.id) + + uri_to_message_map = _get_io(node_execs, wf_execution, show_io, verbose) + if wf_execution is not None: + _render_workflow_execution(wf_execution, uri_to_message_map, show_io, verbose) + + _click.echo("\n\tNode Executions:\n") + for ne in sorted(node_execs, key=lambda x: x.closure.started_at): + if ne.id.node_id == "start-node": + continue + _click.echo("\t\tID: {}\n".format(_tt(ne.id.node_id))) + _click.echo("\t\t\t{:15} ".format("Status:"), nl=False) + _secho_node_execution_status(ne.closure.phase) + _click.echo("\t\t\t{:15} {:60} ".format("Started:", _tt(ne.closure.started_at))) + _click.echo("\t\t\t{:15} {:60} ".format("Duration:", _tt(ne.closure.duration))) + _click.echo( + "\t\t\t{:15} {}".format( + "Input:", + _prefix_lines( + "\t\t\t{:15} ".format(""), + uri_to_message_map.get(ne.input_uri, ne.input_uri), + ), + ) + ) + if ne.closure.output_uri: + _click.echo( + "\t\t\t{:15} {}".format( + "Output:", + _prefix_lines( + "\t\t\t{:15} ".format(""), + uri_to_message_map.get(ne.closure.output_uri, ne.closure.output_uri), + ), + ) + ) + if ne.closure.error is not None: + _click.secho( + _prefix_lines("\t\t\t", _render_error(ne.closure.error)), + bold=True, + fg="red", + ) + + task_executions = node_executions_to_task_executions.get(ne.id, []) + if len(task_executions) > 0: + _click.echo("\n\t\t\tTask Executions:\n") + for te in sorted(task_executions, key=lambda x: x.id.retry_attempt): + _click.echo("\t\t\t\tAttempt {}:\n".format(te.id.retry_attempt)) + _click.echo("\t\t\t\t\t{:15} {:60} ".format("Created:", _tt(te.closure.created_at))) + _click.echo("\t\t\t\t\t{:15} {:60} ".format("Started:", _tt(te.closure.started_at))) + _click.echo("\t\t\t\t\t{:15} {:60} ".format("Updated:", _tt(te.closure.updated_at))) + _click.echo("\t\t\t\t\t{:15} {:60} ".format("Duration:", _tt(te.closure.duration))) + _click.echo("\t\t\t\t\t{:15} ".format("Status:"), nl=False) + _secho_task_execution_status(te.closure.phase) + if len(te.closure.logs) == 0: + _click.echo("\t\t\t\t\t{:15} {:60} ".format("Logs:", "(None Found Yet)")) + else: + _click.echo("\t\t\t\t\tLogs:\n") + for log in sorted(te.closure.logs, key=lambda x: x.name): + _click.echo("\t\t\t\t\t\t{:8} {}".format("Name:", log.name)) + _click.echo("\t\t\t\t\t\t{:8} {}\n".format("URI:", log.uri)) + + if te.closure.error is not None: + _click.secho( + _prefix_lines("\t\t\t\t\t", _render_error(te.closure.error)), + bold=True, + fg="red", + ) + + if te.is_parent: + _click.echo( + "\t\t\t\t\t{:15} {:60} ".format( + "Subtasks:", + "flyte-cli get-child-executions -h {host}{insecure} -u {urn}".format( + host=host, + urn=_tt(cli_identifiers.TaskExecutionIdentifier.promote_from_model(te.id)), + insecure=" --insecure" if insecure else "", + ), + ) + ) + _click.echo() + _click.echo() + _click.echo() + + +@_flyte_cli.command("get-execution", cls=_FlyteSubCommand) +@_urn_option +@_host_option +@_insecure_option +@_show_io_option +@_verbose_option +def get_execution(urn, host, insecure, show_io, verbose): + """ + Get the detail information of a certain execution identified by the URN of that launch plan. + The URN of an execution is in the form of ``ex:::`` + """ + _welcome_message() + client = _get_client(host, insecure) + e = client.get_execution(cli_identifiers.WorkflowExecutionIdentifier.from_python_std(urn)) + node_execs = _get_all_node_executions(client, workflow_execution_identifier=e.id) + _render_node_executions(client, node_execs, show_io, verbose, host, insecure, wf_execution=e) + + +@_flyte_cli.command("get-child-executions", cls=_FlyteSubCommand) +@_urn_option +@_host_option +@_insecure_option +@_show_io_option +@_verbose_option +def get_child_executions(urn, host, insecure, show_io, verbose): + _welcome_message() + client = _get_client(host, insecure) + node_execs = _get_all_node_executions( + client, + task_execution_identifier=cli_identifiers.TaskExecutionIdentifier.from_python_std(urn), + ) + _render_node_executions(client, node_execs, show_io, verbose, host, insecure) + + +@_flyte_cli.command("register-project", cls=_FlyteSubCommand) +@_project_identifier_option +@_project_name_option +@_project_description_option +@_host_option +@_insecure_option +def register_project(identifier, name, description, host, insecure): + """ + Register a new project. + + """ + _welcome_message() + client = _get_client(host, insecure) + client.register_project(_Project(identifier, name, description)) + _click.echo("Registered project [id: {}, name: {}, description: {}]".format(identifier, name, description)) + + +@_flyte_cli.command("list-projects", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_token_option +@_limit_option +@_show_all_option +@_filter_option +@_sort_by_option +def list_projects(host, insecure, token, limit, show_all, filter, sort_by): + """ + List projects. + + """ + _welcome_message() + client = _get_client(host, insecure) + + _click.echo("Projects Found\n") + while True: + projects, next_token = client.list_projects_paginated( + limit=limit, + token=token, + filters=[_filters.Filter.from_python_std(f) for f in filter], + sort_by=_admin_common.Sort.from_python_std(sort_by) if sort_by else None, + ) + for p in projects: + _click.echo("\t{}".format(_tt(p.id))) + + if show_all is not True: + if next_token: + _click.echo("Received next token: {}\n".format(next_token)) + break + if not next_token: + break + token = next_token + _click.echo("") + + +@_flyte_cli.command("archive-project", cls=_FlyteSubCommand) +@_project_identifier_option +@_host_option +@_insecure_option +def archive_project(identifier, host, insecure): + """ + Archive a project. + + """ + _welcome_message() + client = _get_client(host, insecure) + + client.update_project(_Project.archived_project(identifier)) + _click.echo("Archived project [id: {}]".format(identifier)) + + +@_flyte_cli.command("activate-project", cls=_FlyteSubCommand) +@_project_identifier_option +@_host_option +@_insecure_option +def activate_project(identifier, host, insecure): + """ + Activate a project. + + """ + _welcome_message() + client = _get_client(host, insecure) + client.update_project(_Project.active_project(identifier)) + _click.echo("Activated project [id: {}]".format(identifier)) + + +_resource_map = { + _identifier_pb2.LAUNCH_PLAN: _launch_plan_pb2.LaunchPlan, + _identifier_pb2.WORKFLOW: _workflow_pb2.WorkflowSpec, + _identifier_pb2.TASK: _task_pb2.TaskSpec, +} + + +def _extract_pair( + object_file: str, + resource_type: int, + project: str, + domain: str, + version: str, + patches: Dict[int, Callable[[_GeneratedProtocolMessageType], _GeneratedProtocolMessageType]], +) -> Tuple[ + _identifier_pb2.Identifier, + Union[_core_tasks_pb2.TaskTemplate, _core_workflow_pb2.WorkflowTemplate, _launch_plan_pb2.LaunchPlanSpec], +]: + """ + :param Text identifier_file: + :param Text object_file: + :rtype: (flyteidl.core.identifier_pb2.Identifier, T) + """ + if resource_type not in _resource_map: + raise _user_exceptions.FlyteAssertion( + f"Resource type found in proto file name [{resource_type}] invalid, " + "must be 1 (task), 2 (workflow) or 3 (launch plan)" + ) + entity = utils.load_proto_from_file(_resource_map[resource_type], object_file) + registerable_identifier, registerable_entity = hydrate_registration_parameters( + resource_type, project, domain, version, entity + ) + patch_fn = patches.get(resource_type) + if patch_fn: + registerable_entity = patch_fn(registerable_entity) + return registerable_identifier, registerable_entity + + +def _extract_files( + project: str, + domain: str, + version: str, + file_paths: List[str], + patches: Dict[int, Callable[[_GeneratedProtocolMessageType], _GeneratedProtocolMessageType]] = None, +): + """ + :param file_paths: + :rtype: List[(flyteidl.core.identifier_pb2.Identifier, T)] + """ + # Get a manual iterator because we're going to grab files two at a time. + # The identifier file will always come first because the names are always the same and .identifier.pb sorts before + # .pb + + results = [] + for proto_file in file_paths: + # Serialized proto files are of the form: 12_foo.bar_1.pb + # Where 12 indicates it is the 12 file to process in order and 1 that is of resource type 1, or TASK. + resource_type = int(proto_file[-4]) + id, entity = _extract_pair(proto_file, resource_type, project, domain, version, patches or {}) + results.append((id, entity)) + + return results + + +def _get_patch_launch_plan_fn( + assumable_iam_role: str = None, kubernetes_service_account: str = None, output_location_prefix: str = None +) -> Callable[[_GeneratedProtocolMessageType], _GeneratedProtocolMessageType]: + def patch_launch_plan(entity: _GeneratedProtocolMessageType) -> _GeneratedProtocolMessageType: + """ + Updates launch plans during registration to add a customizable auth role that overrides any values set in + the flyte config and/or a custom output_location_prefix. + """ + # entity is of type flyteidl.admin.launch_plan_pb2.LaunchPlanSpec + entity.spec.auth_role.CopyFrom( + _AuthRole( + assumable_iam_role=assumable_iam_role, + kubernetes_service_account=kubernetes_service_account, + ).to_flyte_idl(), + ) + + if output_location_prefix is not None: + entity.spec.raw_output_data_config.CopyFrom( + _RawOutputDataConfig(output_location_prefix=output_location_prefix).to_flyte_idl() + ) + + _click.echo( + f"IAM_Role: {assumable_iam_role}, ServiceAccount: {kubernetes_service_account}," + f" OutputLocationPrefix: {output_location_prefix}" + ) + + return entity + + return patch_launch_plan + + +def _extract_and_register( + client: _friendly_client.SynchronousFlyteClient, + project: str, + domain: str, + version: str, + file_paths: List[str], + patches: Dict[int, Callable[[_GeneratedProtocolMessageType], _GeneratedProtocolMessageType]] = None, +): + flyte_entities_list = _extract_files(project, domain, version, file_paths, patches) + for id, flyte_entity in flyte_entities_list: + _click.secho(f"Registering {id}", fg="yellow") + try: + if id.resource_type == _identifier_pb2.LAUNCH_PLAN: + client.raw.create_launch_plan(_launch_plan_pb2.LaunchPlanCreateRequest(id=id, spec=flyte_entity.spec)) + elif id.resource_type == _identifier_pb2.TASK: + client.raw.create_task(_task_pb2.TaskCreateRequest(id=id, spec=flyte_entity)) + elif id.resource_type == _identifier_pb2.WORKFLOW: + client.raw.create_workflow(_workflow_pb2.WorkflowCreateRequest(id=id, spec=flyte_entity)) + else: + raise _user_exceptions.FlyteAssertion( + f"Only tasks, launch plans, and workflows can be called with this function, " + f"resource type {id.resource_type} was passed" + ) + except _user_exceptions.FlyteEntityAlreadyExistsException: + _click.secho(f"Skipping because already registered {id}", fg="cyan") + + _click.echo(f"Finished scanning {len(flyte_entities_list)} files") + + +@_flyte_cli.command("register-files", cls=_FlyteSubCommand) +@_click.option(*_PROJECT_FLAGS, required=True, help="The project namespace to register with.") +@_click.option(*_DOMAIN_FLAGS, required=True, help="The domain namespace to register with.") +@_click.option(*_VERSION_FLAGS, required=True, help="The entity version to register with") +@_host_option +@_insecure_option +@_assumable_iam_role_option +@_kubernetes_service_acct_option +@_output_location_prefix_option +@_files_argument +def register_files( + project, + domain, + version, + host, + insecure, + assumable_iam_role, + kubernetes_service_account, + output_location_prefix, + files, +): + """ + Given a list of files, this will (after sorting the input list), attempt to register them against Flyte Admin. + This command expects the files to be the output of the pyflyte serialize command. See the code there for more + information. Valid files need to be:\n + * Ordered in the order that you want registration to happen. pyflyte should have done the topological sort + for you and produced file that have a prefix that sets the correct order.\n + * Of the correct type. That is, they should be the serialized form of one of these Flyte IDL objects + (or an identifier object).\n + - flyteidl.admin.launch_plan_pb2.LaunchPlan for launch plans\n + - flyteidl.admin.workflow_pb2.WorkflowSpec for workflows\n + - flyteidl.admin.task_pb2.TaskSpec for tasks\n + + :param host: + :param insecure: + :param files: + :return: + """ + _welcome_message() + files = list(files) + files.sort() + _click.secho("Parsing files...", fg="green", bold=True) + for f in files: + _click.echo(f" {f}") + + patches = { + _identifier_pb2.LAUNCH_PLAN: _get_patch_launch_plan_fn( + assumable_iam_role, kubernetes_service_account, output_location_prefix + ) + } + client = _get_client(host, insecure) + _extract_and_register(client, project, domain, version, files, patches) + + +def _substitute_fast_register_task_args(args: List[str], full_remote_path: str, dest_dir: str) -> List[str]: + complete_args = [] + for arg in args: + if arg == "{{ .remote_package_path }}": + arg = full_remote_path + elif arg == "{{ .dest_dir }}": + arg = dest_dir if dest_dir else "." + complete_args.append(arg) + return complete_args + + +@_flyte_cli.command("fast-register-files", cls=_FlyteSubCommand) +@_click.option(*_PROJECT_FLAGS, required=True, help="The project namespace to register with.") +@_click.option(*_DOMAIN_FLAGS, required=True, help="The domain namespace to register with.") +@_click.option( + *_VERSION_FLAGS, + required=False, + help="Version to register entities with. This is normally computed deterministically from your code, but you can " + "override that here", +) +@_host_option +@_insecure_option +@_click.option("--additional-distribution-dir", required=True, help="Location for additional distributions") +@_click.option( + "--dest-dir", + type=str, + help="[Optional] The output directory for code which is downloaded during fast registration, " + "if the current working directory at the time of installation is not desired", +) +@_assumable_iam_role_option +@_kubernetes_service_acct_option +@_output_location_prefix_option +@_files_argument +def fast_register_files( + project, + domain, + version, + host, + insecure, + additional_distribution_dir, + dest_dir, + assumable_iam_role, + kubernetes_service_account, + output_location_prefix, + files, +): + """ + Given a list of files, this will (after sorting the input list), attempt to register them against Flyte Admin. + This command expects the files to be the output of the pyflyte serialize command. See the code there for more + information. Valid files need to be:\n + * Ordered in the order that you want registration to happen. pyflyte should have done the topological sort + for you and produced file that have a prefix that sets the correct order.\n + * Of the correct type. That is, they should be the serialized form of one of these Flyte IDL objects + (or an identifier object).\n + - flyteidl.admin.launch_plan_pb2.LaunchPlanSpec for launch plans\n + - flyteidl.admin.workflow_pb2.WorkflowSpec for workflows\n + - flyteidl.admin.task_pb2.TaskSpec for tasks\n + + :param host: + :param insecure: + :param files: + :return: + """ + _welcome_message() + files = list(files) + files.sort() + _click.secho("Parsing files...", fg="green", bold=True) + compressed_source, digest = None, None + pb_files = [] + for f in files: + if f.endswith("tar.gz"): + compressed_source = f + digest = os.path.basename(f).split(".")[0] + else: + _click.echo(f" {f}") + pb_files.append(f) + + if compressed_source is None: + raise _click.UsageError( + "Could not discover compressed source, did you remember to run `pyflyte serialize fast ...`?" + ) + + version = version if version else digest + full_remote_path = _get_additional_distribution_loc(additional_distribution_dir, version) + ctx = FlyteContextManager.current_context() + full_remote_path = ctx.file_access.put_data(compressed_source, full_remote_path) + _click.secho(f"Uploaded compressed code archive {compressed_source} to {full_remote_path}", fg="green") + + def fast_register_task(entity: _GeneratedProtocolMessageType) -> _GeneratedProtocolMessageType: + """ + Updates task definitions during fast-registration in order to use the compatible pyflyte fast execute command at + task execution. + """ + # entity is of type flyteidl.admin.task_pb2.TaskSpec + + if entity.template.HasField("container") and len(entity.template.container.args) > 0: + complete_args = _substitute_fast_register_task_args( + entity.template.container.args, full_remote_path, dest_dir + ) + # Because we're dealing with a proto list, we have to delete the existing args before we can extend the list + # with the substituted ones. + del entity.template.container.args[:] + entity.template.container.args.extend(complete_args) + + if entity.template.HasField("k8s_pod"): + pod_spec_struct = entity.template.k8s_pod.pod_spec + if "containers" in pod_spec_struct: + for idx in range(len(pod_spec_struct["containers"])): + if "args" in pod_spec_struct["containers"][idx]: + # We can directly overwrite the args in the pod spec struct definition. + pod_spec_struct["containers"][idx]["args"] = _substitute_fast_register_task_args( + pod_spec_struct["containers"][idx]["args"], full_remote_path, dest_dir + ) + return entity + + patches = { + _identifier_pb2.TASK: fast_register_task, + _identifier_pb2.LAUNCH_PLAN: _get_patch_launch_plan_fn( + assumable_iam_role, kubernetes_service_account, output_location_prefix + ), + } + client = _get_client(host, insecure) + + _extract_and_register(client, project, domain, version, pb_files, patches) + + +@_flyte_cli.command("update-workflow-meta", cls=_FlyteSubCommand) +@_named_entity_description_option +@_named_entity_state_choice +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +def update_workflow_meta(description, state, host, insecure, project, domain, name): + """ + Updates a workflow entity under the scope specified by {project, domain, name} across versions. + """ + _welcome_message() + client = _get_client(host, insecure) + if state == "active": + state = _named_entity.NamedEntityState.ACTIVE + elif state == "archived": + state = _named_entity.NamedEntityState.ARCHIVED + client.update_named_entity( + _core_identifier.ResourceType.WORKFLOW, + _named_entity.NamedEntityIdentifier(project, domain, name), + _named_entity.NamedEntityMetadata(description, state), + ) + _click.echo("Successfully updated workflow") + + +@_flyte_cli.command("update-task-meta", cls=_FlyteSubCommand) +@_named_entity_description_option +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +def update_task_meta(description, host, insecure, project, domain, name): + """ + Updates a task entity under the scope specified by {project, domain, name} across versions. + """ + _welcome_message() + client = _get_client(host, insecure) + client.update_named_entity( + _core_identifier.ResourceType.TASK, + _named_entity.NamedEntityIdentifier(project, domain, name), + _named_entity.NamedEntityMetadata(description, _named_entity.NamedEntityState.ACTIVE), + ) + _click.echo("Successfully updated task") + + +@_flyte_cli.command("update-launch-plan-meta", cls=_FlyteSubCommand) +@_named_entity_description_option +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +def update_launch_plan_meta(description, host, insecure, project, domain, name): + """ + Updates a launch plan entity under the scope specified by {project, domain, name} across versions. + """ + _welcome_message() + client = _get_client(host, insecure) + client.update_named_entity( + _core_identifier.ResourceType.LAUNCH_PLAN, + _named_entity.NamedEntityIdentifier(project, domain, name), + _named_entity.NamedEntityMetadata(description, _named_entity.NamedEntityState.ACTIVE), + ) + _click.echo("Successfully updated launch plan") + + +@_flyte_cli.command("update-cluster-resource-attributes", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +@_click.option("--attributes", type=(str, str), multiple=True) +def update_cluster_resource_attributes(host, insecure, project, domain, name, attributes): + """ + Sets matchable cluster resource attributes for a project, domain and optionally, workflow name. + The attribute names should match the templatized values you use to configure these resource + attributes in your flyteadmin deployment. See + https://lyft.github.io/flyte/administrator/install/managing_customizable_resources.html#cluster-resources + for more documentation. + + e.g. + $ flyte-cli -h localhost:30081 -p flyteexamples -d development update-cluster-resource-attributes \ + --attributes projectQuotaCpu 1 --attributes projectQuotaMemory 500M + """ + _welcome_message() + client = _get_client(host, insecure) + cluster_resource_attributes = _ClusterResourceAttributes({attribute[0]: attribute[1] for attribute in attributes}) + matching_attributes = _MatchingAttributes(cluster_resource_attributes=cluster_resource_attributes) + + if name is not None: + client.update_workflow_attributes(project, domain, name, matching_attributes) + _click.echo( + "Successfully updated cluster resource attributes for project: {}, domain: {}, and workflow: {}".format( + project, domain, name + ) + ) + else: + client.update_project_domain_attributes(project, domain, matching_attributes) + _click.echo( + "Successfully updated cluster resource attributes for project: {} and domain: {}".format(project, domain) + ) + + +@_flyte_cli.command("update-execution-queue-attributes", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +@_click.option("--tags", multiple=True, help="Tag(s) to be applied.") +def update_execution_queue_attributes(host, insecure, project, domain, name, tags): + """ + Tags used for assigning execution queues for tasks belonging to a project, domain and optionally, workflow name. + + e.g. + $ flyte-cli -h localhost:30081 -p flyteexamples -d development update-execution-queue-attributes \ + --tags critical --tags gpu_intensive + """ + _welcome_message() + client = _get_client(host, insecure) + execution_queue_attributes = _ExecutionQueueAttributes(list(tags)) + matching_attributes = _MatchingAttributes(execution_queue_attributes=execution_queue_attributes) + + if name is not None: + client.update_workflow_attributes(project, domain, name, matching_attributes) + _click.echo( + "Successfully updated execution queue attributes for project: {}, domain: {}, and workflow: {}".format( + project, domain, name + ) + ) + else: + client.update_project_domain_attributes(project, domain, matching_attributes) + _click.echo( + "Successfully updated execution queue attributes for project: {} and domain: {}".format(project, domain) + ) + + +@_flyte_cli.command("update-execution-cluster-label", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +@_click.option("--value", help="Cluster label for which to schedule matching executions") +def update_execution_cluster_label(host, insecure, project, domain, name, value): + """ + Label value to determine where an execution's task will be run for tasks belonging to a project, domain and + optionally, workflow name. + + e.g. + $ flyte-cli -h localhost:30081 -p flyteexamples -d development update-execution-cluster-label --value foo + """ + _welcome_message() + client = _get_client(host, insecure) + execution_cluster_label = _ExecutionClusterLabel(value) + matching_attributes = _MatchingAttributes(execution_cluster_label=execution_cluster_label) + + if name is not None: + client.update_workflow_attributes(project, domain, name, matching_attributes) + _click.echo( + "Successfully updated execution cluster label for project: {}, domain: {}, and workflow: {}".format( + project, domain, name + ) + ) + else: + client.update_project_domain_attributes(project, domain, matching_attributes) + _click.echo( + "Successfully updated execution cluster label for project: {} and domain: {}".format(project, domain) + ) + + +@_flyte_cli.command("update-plugin-override", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +@_click.option("--task-type", help="Task type for which to apply plugin implementation overrides") +@_click.option("--plugin-id", multiple=True, help="Plugin id(s) to be used in place of the default for the task type.") +@_click.option( + "--missing-plugin-behavior", help="Behavior when no specified plugin_id has an associated handler.", default="FAIL" +) +def update_plugin_override(host, insecure, project, domain, name, task_type, plugin_id, missing_plugin_behavior): + """ + Plugin ids designating non-default plugin handlers to be used for tasks of a certain type. + + e.g. + $ flyte-cli -h localhost:30081 -p flyteexamples -d development update-plugin-override --task-type python \ + --plugin-id my_cool_plugin --plugin-id my_fallback_plugin --missing-plugin-behavior FAIL + """ + _welcome_message() + client = _get_client(host, insecure) + plugin_override = _PluginOverride( + task_type, list(plugin_id), _PluginOverride.string_to_enum(missing_plugin_behavior.upper()) + ) + matching_attributes = _MatchingAttributes(plugin_overrides=_PluginOverrides(overrides=[plugin_override])) + + if name is not None: + client.update_workflow_attributes(project, domain, name, matching_attributes) + _click.echo( + "Successfully updated plugin override for project: {}, domain: {}, and workflow: {}".format( + project, domain, name + ) + ) + else: + client.update_project_domain_attributes(project, domain, matching_attributes) + _click.echo("Successfully updated plugin override for project: {} and domain: {}".format(project, domain)) + + +@_flyte_cli.command("get-matching-attributes", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_project_option +@_domain_option +@_optional_name_option +@_click.option( + "--resource-type", + help="Resource type", + required=True, + type=_click.Choice( + [ + "task_resource", + "cluster_resource", + "execution_queue", + "execution_cluster_label", + "quality_of_service_specification", + ] + ), +) +def get_matching_attributes(host, insecure, project, domain, name, resource_type): + """ + Fetches the matchable resource of the given resource type for this project, domain and optionally workflow name + combination. + """ + _welcome_message() + client = _get_client(host, insecure) + + if name is not None: + attributes = client.get_workflow_attributes( + project, domain, name, _MatchableResource.string_to_enum(resource_type.upper()) + ) + _click.echo("{}".format(attributes)) + else: + attributes = client.get_project_domain_attributes( + project, domain, _MatchableResource.string_to_enum(resource_type.upper()) + ) + _click.echo("{}".format(attributes)) + + +@_flyte_cli.command("list-matching-attributes", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +@_click.option( + "--resource-type", + help="Resource type", + required=True, + type=_click.Choice( + [ + "task_resource", + "cluster_resource", + "execution_queue", + "execution_cluster_label", + "quality_of_service_specification", + ] + ), +) +def list_matching_attributes(host, insecure, resource_type): + """ + Fetches all matchable resources of the given resource type. + """ + _welcome_message() + client = _get_client(host, insecure) + + attributes = client.list_matchable_attributes(_MatchableResource.string_to_enum(resource_type.upper())) + for cfg in attributes.configurations: + _click.secho( + "{:20} {:20} {:20} {:20}\n".format( + _tt(cfg.project), + _tt(cfg.domain), + _tt(cfg.workflow), + _tt(cfg.launch_plan), + ), + fg="blue", + nl=False, + ) + _click.echo("{}".format(cfg.attributes)) + + +@_flyte_cli.command("setup-config", cls=_FlyteSubCommand) +@_host_option +@_insecure_option +def setup_config(host, insecure): + """ + Set-up a default config file. + + """ + _welcome_message() + config_file = _get_config_file_path() + if _get_user_filepath_home() and _os.path.exists(config_file): + _click.secho("Config file already exists at {}".format(_tt(config_file)), fg="blue") + return + + # Before creating check that the directory exists and create if not + config_dir = _os.path.join(_get_user_filepath_home(), _default_config_file_dir) + if not _os.path.isdir(config_dir): + _click.secho( + "Creating default Flyte configuration directory at {}".format(_tt(config_dir)), + fg="blue", + ) + _os.mkdir(config_dir) + + full_host = "http://{}".format(host) if insecure else "https://{}".format(host) + config_url = _urlparse.urljoin(full_host, "config/v1/flyte_client") + response = _requests.get(config_url) + data = response.json() + platform_config = {"url": str(host), "insecure": str(insecure)} + credentials_config = None + if not insecure: + # We can't get credentials config in insecure mode + credentials_config = { + "client_id": data["client_id"], + "redirect_uri": data["redirect_uri"], + "scopes": data["scopes"], + "authorization_metadata_key": data["authorization_metadata_key"], + "auth_mode": "standard", + } + with open(config_file, "w+") as f: + parser = _configparser.ConfigParser() + parser.add_section("platform") + for key in platform_config.keys(): + parser.set("platform", key, platform_config[key]) + if not insecure: + parser.add_section("credentials") + for key in credentials_config.keys(): + # ConfigParser needs all keys to be strings + parser.set("credentials", key, str(credentials_config[key])) + parser.write(f) + _click.secho("Wrote default config file to {}".format(_tt(config_file)), fg="blue") + + +if __name__ == "__main__": + _flyte_cli() diff --git a/flytekit/flytekit/clis/helpers.py b/flytekit/flytekit/clis/helpers.py new file mode 100644 index 0000000000..6add52096b --- /dev/null +++ b/flytekit/flytekit/clis/helpers.py @@ -0,0 +1,139 @@ +import sys +from typing import Tuple, Union + +import click +from flyteidl.admin.launch_plan_pb2 import LaunchPlan +from flyteidl.admin.task_pb2 import TaskSpec +from flyteidl.admin.workflow_pb2 import WorkflowSpec +from flyteidl.core import identifier_pb2 as _identifier_pb2 +from flyteidl.core import workflow_pb2 as _workflow_pb2 + +from flytekit.configuration import DOMAIN_PLACEHOLDER, PROJECT_PLACEHOLDER, VERSION_PLACEHOLDER + + +def parse_args_into_dict(input_arguments): + """ + Takes a tuple like (u'input_b=mystr', u'input_c=18') and returns a dictionary of input name to the + original string value + + :param Tuple[Text] input_arguments: + :rtype: dict[Text, Text] + """ + + return {split_arg[0]: split_arg[1] for split_arg in [input_arg.split("=", 1) for input_arg in input_arguments]} + + +def str2bool(str): + """ + bool('False') is True in Python, so we need to do some string parsing. Use the same words in ConfigParser + :param Text str: + :rtype: bool + """ + return str.lower() not in ["false", "0", "off", "no"] + + +# TODO Deprecated delete after deleting flyte_cli register +def _hydrate_identifier( + project: str, domain: str, version: str, identifier: _identifier_pb2.Identifier +) -> _identifier_pb2.Identifier: + if not identifier.project or identifier.project == PROJECT_PLACEHOLDER: + identifier.project = project + + if not identifier.domain or identifier.domain == DOMAIN_PLACEHOLDER: + identifier.domain = domain + + if not identifier.version or identifier.version == VERSION_PLACEHOLDER: + identifier.version = version + return identifier + + +# TODO Deprecated delete after deleting flyte_cli register +def _hydrate_node(project: str, domain: str, version: str, node: _workflow_pb2.Node) -> _workflow_pb2.Node: + if node.HasField("task_node"): + task_node = node.task_node + task_node.reference_id.CopyFrom(_hydrate_identifier(project, domain, version, task_node.reference_id)) + node.task_node.CopyFrom(task_node) + elif node.HasField("workflow_node"): + workflow_node = node.workflow_node + if workflow_node.HasField("launchplan_ref"): + workflow_node.launchplan_ref.CopyFrom( + _hydrate_identifier(project, domain, version, workflow_node.launchplan_ref) + ) + elif workflow_node.HasField("sub_workflow_ref"): + workflow_node.sub_workflow_ref.CopyFrom( + _hydrate_identifier(project, domain, version, workflow_node.sub_workflow_ref) + ) + elif node.HasField("branch_node"): + node.branch_node.if_else.case.then_node.CopyFrom( + _hydrate_node(project, domain, version, node.branch_node.if_else.case.then_node) + ) + if node.branch_node.if_else.other is not None: + others = [] + for if_block in node.branch_node.if_else.other: + if_block.then_node.CopyFrom(_hydrate_node(project, domain, version, if_block.then_node)) + others.append(if_block) + del node.branch_node.if_else.other[:] + node.branch_node.if_else.other.extend(others) + if node.branch_node.if_else.HasField("else_node"): + node.branch_node.if_else.else_node.CopyFrom( + _hydrate_node(project, domain, version, node.branch_node.if_else.else_node) + ) + return node + + +# TODO Deprecated delete after deleting flyte_cli register +def _hydrate_workflow_template_nodes( + project: str, domain: str, version: str, template: _workflow_pb2.WorkflowTemplate +) -> _workflow_pb2.WorkflowTemplate: + refreshed_nodes = [] + for node in template.nodes: + node = _hydrate_node(project, domain, version, node) + refreshed_nodes.append(node) + # Reassign nodes with the newly hydrated ones. + del template.nodes[:] + template.nodes.extend(refreshed_nodes) + return template + + +# TODO Deprecated delete after deleting flyte_cli register +def hydrate_registration_parameters( + resource_type: int, + project: str, + domain: str, + version: str, + entity: Union[LaunchPlan, WorkflowSpec, TaskSpec], +) -> Tuple[_identifier_pb2.Identifier, Union[LaunchPlan, WorkflowSpec, TaskSpec]]: + """ + This is called at registration time to fill out identifier fields (e.g. project, domain, version) that are mutable. + """ + + if resource_type == _identifier_pb2.LAUNCH_PLAN: + identifier = _hydrate_identifier(project, domain, version, entity.id) + entity.spec.workflow_id.CopyFrom(_hydrate_identifier(project, domain, version, entity.spec.workflow_id)) + return identifier, entity + + identifier = _hydrate_identifier(project, domain, version, entity.template.id) + entity.template.id.CopyFrom(identifier) + if identifier.resource_type == _identifier_pb2.TASK: + return identifier, entity + + # Workflows (the only possible entity type at this point) are a little more complicated. + # Workflow nodes that are defined inline with the workflows will be missing project/domain/version so we fill those + # in now. + # (entity is of type flyteidl.admin.workflow_pb2.WorkflowSpec) + entity.template.CopyFrom(_hydrate_workflow_template_nodes(project, domain, version, entity.template)) + refreshed_sub_workflows = [] + for sub_workflow in entity.sub_workflows: + refreshed_sub_workflow = _hydrate_workflow_template_nodes(project, domain, version, sub_workflow) + refreshed_sub_workflow.id.CopyFrom(_hydrate_identifier(project, domain, version, refreshed_sub_workflow.id)) + refreshed_sub_workflows.append(refreshed_sub_workflow) + # Reassign subworkflows with the newly hydrated ones. + del entity.sub_workflows[:] + entity.sub_workflows.extend(refreshed_sub_workflows) + return identifier, entity + + +def display_help_with_error(ctx: click.Context, message: str): + click.echo(f"{ctx.get_help()}\n") + click.secho(message, fg="red") + sys.exit(1) diff --git a/flytekit/flytekit/clis/sdk_in_container/__init__.py b/flytekit/flytekit/clis/sdk_in_container/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/clis/sdk_in_container/backfill.py b/flytekit/flytekit/clis/sdk_in_container/backfill.py new file mode 100644 index 0000000000..7723a98f44 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/backfill.py @@ -0,0 +1,182 @@ +import typing +from datetime import datetime, timedelta + +import rich_click as click + +from flytekit import WorkflowFailurePolicy +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context +from flytekit.clis.sdk_in_container.utils import domain_option_dec, project_option_dec +from flytekit.interaction.click_types import DateTimeType, DurationParamType + +_backfill_help = """ +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. +""" + + +def resolve_backfill_window( + from_date: datetime = None, + to_date: datetime = None, + backfill_window: timedelta = None, +) -> typing.Tuple[datetime, datetime]: + """ + Resolves the from_date -> to_date + """ + if from_date and to_date and backfill_window: + raise click.BadParameter("Setting from-date, to-date and backfill_window at the same time is not allowed.") + if not (from_date or to_date): + raise click.BadParameter( + "One of following pairs are required -> (from-date, to-date) | (from-date, backfill_window) |" + " (to-date, backfill_window)" + ) + if from_date and to_date: + pass + elif not backfill_window: + raise click.BadParameter("One of start-date and end-date are needed with duration") + elif from_date: + to_date = from_date + backfill_window + else: + from_date = to_date - backfill_window + return from_date, to_date + + +@click.command("backfill", help=_backfill_help) +@project_option_dec +@domain_option_dec +@click.option( + "-v", + "--version", + required=False, + type=str, + default=None, + help="Version for the registered workflow. If not specified it is auto-derived using the start and end date", +) +@click.option( + "-n", + "--execution-name", + required=False, + type=str, + default=None, + help="Create a named execution for the backfill. This can prevent launching multiple executions.", +) +@click.option( + "--dry-run", + required=False, + type=bool, + is_flag=True, + default=False, + show_default=True, + help="Just generate the workflow - do not register or execute", +) +@click.option( + "--parallel/--serial", + required=False, + type=bool, + is_flag=True, + default=False, + show_default=True, + help="All backfill steps can be run in parallel (limited by max-parallelism), if using ``--parallel.``" + " Else all steps will be run sequentially [``--serial``].", +) +@click.option( + "--execute/--do-not-execute", + required=False, + type=bool, + is_flag=True, + default=True, + show_default=True, + help="Generate the workflow and register, do not execute", +) +@click.option( + "--from-date", + required=False, + type=DateTimeType(), + default=None, + help="Date from which the backfill should begin. Start date is inclusive.", +) +@click.option( + "--to-date", + required=False, + type=DateTimeType(), + default=None, + help="Date to which the backfill should run_until. End date is inclusive", +) +@click.option( + "--backfill-window", + required=False, + type=DurationParamType(), + default=None, + help="Timedelta for number of days, minutes hours after the from-date or before the to-date to compute the " + "backfills between. This is needed with from-date / to-date. Optional if both from-date and to-date are " + "provided", +) +@click.option( + "--fail-fast/--no-fail-fast", + required=False, + type=bool, + is_flag=True, + default=True, + show_default=True, + help="If set to true, the backfill will fail immediately (WorkflowFailurePolicy.FAIL_IMMEDIATELY) if any of the " + "backfill steps fail. If set to false, the backfill will continue to run even if some of the backfill steps " + "fail (WorkflowFailurePolicy.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE).", +) +@click.argument( + "launchplan", + required=True, + type=str, +) +@click.argument( + "launchplan-version", + required=False, + type=str, + default=None, +) +@click.pass_context +def backfill( + ctx: click.Context, + project: str, + domain: str, + from_date: datetime, + to_date: datetime, + backfill_window: timedelta, + launchplan: str, + launchplan_version: str, + dry_run: bool, + execute: bool, + parallel: bool, + execution_name: str, + version: str, + fail_fast: bool, +): + from_date, to_date = resolve_backfill_window(from_date, to_date, backfill_window) + remote = get_and_save_remote_with_click_context(ctx, project, domain) + try: + entity = remote.launch_backfill( + project=project, + domain=domain, + from_date=from_date, + to_date=to_date, + launchplan=launchplan, + launchplan_version=launchplan_version, + execution_name=execution_name, + version=version, + dry_run=dry_run, + execute=execute, + parallel=parallel, + failure_policy=WorkflowFailurePolicy.FAIL_IMMEDIATELY + if fail_fast + else WorkflowFailurePolicy.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE, + ) + 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/flytekit/clis/sdk_in_container/build.py b/flytekit/flytekit/clis/sdk_in_container/build.py new file mode 100644 index 0000000000..d11865fc8e --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/build.py @@ -0,0 +1,95 @@ +import typing +from dataclasses import dataclass + +import rich_click as click +from typing_extensions import OrderedDict + +from flytekit.clis.sdk_in_container.run import RunCommand, RunLevelParams, WorkflowCommand +from flytekit.clis.sdk_in_container.utils import make_click_option_field +from flytekit.configuration import ImageConfig, SerializationSettings +from flytekit.core.base_task import PythonTask +from flytekit.core.workflow import PythonFunctionWorkflow +from flytekit.tools.translator import get_serializable + + +@dataclass +class BuildParams(RunLevelParams): + fast: bool = make_click_option_field( + click.Option( + param_decls=["--fast"], + required=False, + is_flag=True, + default=False, + show_default=True, + 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 + build_params: BuildParams = ctx.obj + + serialization_settings = SerializationSettings( + project=build_params.project, + domain=build_params.domain, + image_config=ImageConfig.auto_default_image(), + ) + if not build_params.fast: + serialization_settings.source_root = build_params.computed_params.project_root + + _ = get_serializable(m, settings=serialization_settings, entity=entity, options=options) + + return _build + + +class BuildWorkflowCommand(WorkflowCommand): + """ + click multicommand at the python file layer, subcommands should be all the workflows in the file. + """ + + def _create_command( + self, + ctx: click.Context, + entity_name: str, + run_level_params: RunLevelParams, + loaded_entity: typing.Any, + is_workflow: bool, + ): + cmd = click.Command( + name=entity_name, + callback=build_command(ctx, loaded_entity), + help=f"Build an image for {run_level_params.computed_params.module}.{entity_name}.", + ) + return cmd + + +class BuildCommand(RunCommand): + """ + A click command group for building a image for flyte workflows & tasks in a file. + """ + + _run_params = BuildParams + + def list_commands(self, ctx, *args, **kwargs): + return super().list_commands(ctx, add_remote=False) + + def get_command(self, ctx, filename): + super().get_command(ctx, filename) + return BuildWorkflowCommand(filename, name=filename, help=f"Build an image for [workflow|task] from {filename}") + + +_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/flytekit/clis/sdk_in_container/constants.py b/flytekit/flytekit/clis/sdk_in_container/constants.py new file mode 100644 index 0000000000..dd9c6f4e87 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/constants.py @@ -0,0 +1,8 @@ +CTX_PROJECT = "project" +CTX_DOMAIN = "domain" +CTX_VERSION = "version" +CTX_TEST = "test" +CTX_PACKAGES = "pkgs" +CTX_NOTIFICATIONS = "notifications" +CTX_CONFIG_FILE = "config_file" +CTX_VERBOSE = "verbose" diff --git a/flytekit/flytekit/clis/sdk_in_container/fetch.py b/flytekit/flytekit/clis/sdk_in_container/fetch.py new file mode 100644 index 0000000000..841ca3f840 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/fetch.py @@ -0,0 +1,48 @@ +import typing + +import rich_click as click +from rich import print +from rich.panel import Panel +from rich.pretty import Pretty + +from flytekit import Literal +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context +from flytekit.core.type_engine import LiteralsResolver +from flytekit.interaction.string_literals import literal_map_string_repr, literal_string_repr +from flytekit.remote import FlyteRemote + + +@click.command("fetch") +@click.option( + "--recursive", + "-r", + is_flag=True, + help="Fetch recursively, all variables in the URI. This is not needed for directories as they" + " are automatically recursively downloaded.", +) +@click.argument("flyte-data-uri", type=str, required=True, metavar="FLYTE-DATA-URI (format flyte://...)") +@click.argument( + "download-to", type=click.Path(), required=False, default=None, metavar="DOWNLOAD-TO Local path (optional)" +) +@click.pass_context +def fetch(ctx: click.Context, recursive: bool, flyte_data_uri: str, download_to: typing.Optional[str] = None): + """ + Retrieve Inputs/Outputs for a Flyte Execution or any of the inner node executions from the remote server. + + The URI can be retrieved from the Flyte Console, or by invoking the get_data API. + """ + + remote: FlyteRemote = get_and_save_remote_with_click_context(ctx, project="flytesnacks", domain="development") + click.secho(f"Fetching data from {flyte_data_uri}...", dim=True) + data = remote.get(flyte_data_uri) + if isinstance(data, Literal): + p = literal_string_repr(data) + elif isinstance(data, LiteralsResolver): + p = literal_map_string_repr(data.literals) + else: + p = data + pretty = Pretty(p) + panel = Panel(pretty) + print(panel) + if download_to: + remote.download(data, download_to, recursive=recursive) diff --git a/flytekit/flytekit/clis/sdk_in_container/get.py b/flytekit/flytekit/clis/sdk_in_container/get.py new file mode 100644 index 0000000000..473dd82b15 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/get.py @@ -0,0 +1,87 @@ +import rich_click as click +from google.protobuf.json_format import MessageToJson +from rich import print +from rich.console import Console +from rich.table import Table + +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context +from flytekit.clis.sdk_in_container.utils import domain_option_dec, project_option_dec +from flytekit.interfaces.cli_identifiers import Identifier +from flytekit.models.admin.common import Sort +from flytekit.models.common import NamedEntityIdentifier +from flytekit.models.core.identifier import ResourceType +from flytekit.models.launch_plan import LaunchPlanState +from flytekit.remote import FlyteRemote + + +@click.group("get") +@click.pass_context +def get(ctx: click.Context): + """ + Get a single or multiple remote objects. + """ + pass + + +@get.command() +@click.option("--active-only", "--scheduled", is_flag=True, default=False, help="Only return active launchplans.") +@project_option_dec +@domain_option_dec +@click.option("--limit", "-l", type=int, default=1000, help="Limit the number of launchplans returned.") +@click.argument("launchplan-name", type=str, required=False, metavar="LAUNCHPLAN-NAME") +@click.argument("version", type=str, required=False, metavar="LAUNCHPLAN-VERSION") +@click.pass_context +def launchplan( + ctx: click.Context, project: str, domain: str, limit: int, active_only: bool, launchplan_name: str, version: str +): + """ + Interact with launchplans. + """ + remote: FlyteRemote = get_and_save_remote_with_click_context(ctx, project="flytesnacks", domain="development") + + console = Console() + if launchplan_name: + if not version: + lps, _ = remote.client.list_launch_plans_paginated( + NamedEntityIdentifier(project, domain, name=launchplan_name), + limit=1, + sort_by=Sort(key="updated_at", direction=Sort.Direction.DESCENDING), + ) + if len(lps) > 0: + version = lps[0].id.version + lp = remote.client.get_launch_plan( + Identifier(ResourceType.LAUNCH_PLAN, project, domain, launchplan_name, version) + ) + j = MessageToJson(lp.to_flyte_idl()) + print(j) + return + + title = f"LaunchPlans for {project}/{domain}" + if active_only: + title += " (active only)" + lps, _ = remote.client.list_active_launch_plans_paginated(project, domain, limit=limit) + else: + lps, _ = remote.client.list_launch_plans_paginated( + NamedEntityIdentifier(project, domain), + limit=limit, + sort_by=Sort(key="updated_at", direction=Sort.Direction.DESCENDING), + ) + + table = Table(title=title) + + table.add_column("Name", justify="right", style="cyan") + table.add_column("Version", justify="right", style="cyan") + table.add_column("State", justify="right", style="green") + table.add_column("Schedule", justify="right", style="green") + + for lp in lps: + s = LaunchPlanState.enum_to_string(lp.closure.state) + schedule = "None" + if lp.spec.entity_metadata.schedule: + if lp.spec.entity_metadata.schedule.cron_schedule: + schedule = lp.spec.entity_metadata.schedule.cron_schedule.schedule + elif lp.spec.entity_metadata.schedule.rate: + schedule = f"{lp.spec.entity_metadata.schedule.rate.value} {lp.spec.entity_metadata.schedule.rate.unit}" + table.add_row(lp.id.name, lp.id.version, s, schedule) + + console.print(table) diff --git a/flytekit/flytekit/clis/sdk_in_container/helpers.py b/flytekit/flytekit/clis/sdk_in_container/helpers.py new file mode 100644 index 0000000000..5ec4b9b262 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/helpers.py @@ -0,0 +1,63 @@ +from dataclasses import replace +from typing import Optional + +import rich_click as click + +from flytekit.clis.sdk_in_container.constants import CTX_CONFIG_FILE +from flytekit.configuration import ImageConfig +from flytekit.configuration.plugin import get_plugin +from flytekit.remote.remote import FlyteRemote + +FLYTE_REMOTE_INSTANCE_KEY = "flyte_remote" + + +def get_and_save_remote_with_click_context( + ctx: click.Context, + project: str, + domain: str, + save: bool = True, + data_upload_location: Optional[str] = None, +) -> FlyteRemote: + """ + NB: This function will by default mutate the click Context.obj dictionary, adding a remote key with value + of the created FlyteRemote object. + + :param ctx: the click context object + :param project: default project for the remote instance + :param domain: default domain + :param save: If false, will not mutate the context.obj dict + :param data_upload_location: if specified, will set the data upload location for the remote instance + :return: FlyteRemote instance + """ + if ctx.obj.get(FLYTE_REMOTE_INSTANCE_KEY) is not None: + return ctx.obj[FLYTE_REMOTE_INSTANCE_KEY] + cfg_file_location = ctx.obj.get(CTX_CONFIG_FILE) + r = get_plugin().get_remote(cfg_file_location, project, domain, data_upload_location) + if save: + ctx.obj[FLYTE_REMOTE_INSTANCE_KEY] = r + return r + + +def patch_image_config(config_file: Optional[str], image_config: ImageConfig) -> ImageConfig: + """ + Merge ImageConfig object with images defined in config file + """ + # Images come from three places: + # * The default flytekit images, which are already supplied by the base run_level_params. + # * The images provided by the user on the command line. + # * The images provided by the user via the config file, if there is one. (Images on the command line should + # override all). + # + # However, the run_level_params already contains both the default flytekit images (lowest priority), as well + # as the images from the command line (highest priority). So when we read from the config file, we only + # want to add in the images that are missing, including the default, if that's also missing. + additional_image_names = set([v.name for v in image_config.images]) + new_additional_images = [v for v in image_config.images] + new_default = image_config.default_image + if config_file: + cfg_ic = ImageConfig.auto(config_file=config_file) + new_default = new_default or cfg_ic.default_image + for addl in cfg_ic.images: + if addl.name not in additional_image_names: + new_additional_images.append(addl) + return replace(image_config, default_image=new_default, images=new_additional_images) diff --git a/flytekit/flytekit/clis/sdk_in_container/init.py b/flytekit/flytekit/clis/sdk_in_container/init.py new file mode 100644 index 0000000000..23df6ab62d --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/init.py @@ -0,0 +1,37 @@ +import rich_click as click +from cookiecutter.main import cookiecutter + + +@click.command("init") +@click.option( + "--template", + default="basic-template-imagespec", + help="cookiecutter template folder name to be used in the repo - https://github.com/flyteorg/flytekit-python-template.git", +) +@click.argument("project-name") +def init(template, project_name): + """ + Create flyte-ready projects. + """ + config = { + "project_name": project_name, + "app": "flyte", + "workflow": "my_wf", + } + cookiecutter( + "https://github.com/flyteorg/flytekit-python-template.git", + checkout="main", + no_input=True, + # We do not want to clobber existing files/directories. + overwrite_if_exists=False, + extra_context=config, + # By specifying directory we can have multiple templates in the same repository, + # as described in https://cookiecutter.readthedocs.io/en/1.7.2/advanced/directories.html. + # The idea is to extend the number of templates, each in their own subdirectory, for example + # a tensorflow-based example. + directory=template, + ) + + click.echo( + f"Visit the {project_name} directory and follow the next steps in the Getting started guide (https://docs.flyte.org/en/latest/getting_started.html) to proceed." + ) diff --git a/flytekit/flytekit/clis/sdk_in_container/launchplan.py b/flytekit/flytekit/clis/sdk_in_container/launchplan.py new file mode 100644 index 0000000000..8914c8f3bf --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/launchplan.py @@ -0,0 +1,69 @@ +import rich_click as click +from rich.progress import Progress + +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context +from flytekit.clis.sdk_in_container.utils import domain_option_dec, project_option_dec +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) +@project_option_dec +@domain_option_dec +@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, data_upload_location="flyte://data") + with Progress() as progress: + t1 = progress.add_task(f"[cyan] {'Activating' if activate else 'Deactivating'}...", total=1) + try: + progress.start_task(t1) + launchplan = remote.fetch_launch_plan( + project=project, + domain=domain, + name=launchplan, + version=launchplan_version, + ) + progress.advance(t1) + + state = LaunchPlanState.ACTIVE if activate else LaunchPlanState.INACTIVE + remote.client.update_launch_plan(id=launchplan.id, state=state) + progress.advance(t1) + progress.update(t1, completed=True, visible=False) + 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/flytekit/clis/sdk_in_container/local_cache.py b/flytekit/flytekit/clis/sdk_in_container/local_cache.py new file mode 100644 index 0000000000..b0923b842a --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/local_cache.py @@ -0,0 +1,22 @@ +import rich_click as click + +from flytekit.core.local_cache import LocalTaskCache + + +@click.group("local-cache") +def local_cache(): + """ + Interact with the local cache. + """ + pass + + +@click.command("clear") +def clear_local_cache(): + """ + This command will remove all stored objects from local cache. + """ + LocalTaskCache.clear() + + +local_cache.add_command(clear_local_cache) diff --git a/flytekit/flytekit/clis/sdk_in_container/metrics.py b/flytekit/flytekit/clis/sdk_in_container/metrics.py new file mode 100644 index 0000000000..40e40a6f70 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/metrics.py @@ -0,0 +1,218 @@ +from datetime import datetime + +import rich_click as click +import yaml +from flyteidl.admin.execution_pb2 import WorkflowExecutionGetMetricsRequest +from flyteidl.core.identifier_pb2 import WorkflowExecutionIdentifier + +from flytekit.clis.sdk_in_container.constants import CTX_DOMAIN, CTX_PROJECT +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context + +CTX_DEPTH = "depth" + +_dump_help = """ +The dump command aggregates workflow execution metrics and displays them. This aggregation is meant to provide an easy +to understand breakdown of where time is spent in a hierarchical manner. + +- execution_id refers to the id of the workflow execution +""" + +_explain_help = """ +The explain command prints each individual execution span and the associated timestamps and Flyte entity reference. +This breakdown provides precise information into exactly how and when Flyte processes a workflow execution. + +- execution_id refers to the id of the workflow execution +""" + + +@click.group("metrics") +@click.option( + "-d", + "--depth", + required=False, + type=int, + default=-1, + help="The depth of Flyte entity hierarchy to traverse when computing metrics for this execution", +) +@click.option( + "-p", + "--project", + required=False, + type=str, + default="flytesnacks", + help="The project of the workflow execution", +) +@click.option( + "-d", + "--domain", + required=False, + type=str, + default="development", + help="The domain of the workflow execution", +) +@click.pass_context +def metrics(ctx: click.Context, depth, domain, project): + ctx.obj[CTX_DEPTH] = depth + ctx.obj[CTX_DOMAIN] = domain + ctx.obj[CTX_PROJECT] = project + + +@click.command("dump", help=_dump_help) +@click.argument("execution_id", type=str) +@click.pass_context +def metrics_dump( + ctx: click.Context, + execution_id: str, +): + depth = ctx.obj[CTX_DEPTH] + domain = ctx.obj[CTX_DOMAIN] + project = ctx.obj[CTX_PROJECT] + + # retrieve remote + remote = get_and_save_remote_with_click_context(ctx, project, domain) + sync_client = remote.client + + # retrieve workflow execution metrics + workflow_execution_id = WorkflowExecutionIdentifier(project=project, domain=domain, name=execution_id) + + request = WorkflowExecutionGetMetricsRequest(id=workflow_execution_id, depth=depth) + response = sync_client.get_execution_metrics(request) + + # aggregate spans and print + id, info = aggregate_reference_span(response.span) + yaml.emitter.Emitter.process_tag = lambda self, *args, **kw: None + print(yaml.dump({id: info}, indent=2)) + + +def aggregate_reference_span(span): + id = "" + id_type = span.WhichOneof("id") + if id_type == "workflow_id": + id = span.workflow_id.name + elif id_type == "node_id": + id = span.node_id.node_id + elif id_type == "task_id": + id = span.task_id.retry_attempt + + spans = aggregate_spans(span.spans) + return id, spans + + +def aggregate_spans(spans): + breakdown = {} + + tasks = {} + nodes = {} + workflows = {} + + for span in spans: + id_type = span.WhichOneof("id") + if id_type == "operation_id": + operation_id = span.operation_id + + start_time = datetime.fromtimestamp(span.start_time.seconds + span.start_time.nanos / 1e9) + end_time = datetime.fromtimestamp(span.end_time.seconds + span.end_time.nanos / 1e9) + total_time = (end_time - start_time).total_seconds() + + if operation_id in breakdown: + breakdown[operation_id] += total_time + else: + breakdown[operation_id] = total_time + else: + id, underlying_span = aggregate_reference_span(span) + + if id_type == "workflow_id": + workflows[id] = underlying_span + elif id_type == "node_id": + nodes[id] = underlying_span + elif id_type == "task_id": + tasks[id] = underlying_span + + for operation_id, total_time in underlying_span["breakdown"].items(): + if operation_id in breakdown: + breakdown[operation_id] += total_time + else: + breakdown[operation_id] = total_time + + span = {"breakdown": breakdown} + + if len(tasks) > 0: + span["task_attempts"] = tasks + if len(nodes) > 0: + span["nodes"] = nodes + if len(workflows) > 0: + span["workflows"] = workflows + + return span + + +@click.command("explain", help=_explain_help) +@click.argument("execution_id", type=str) +@click.pass_context +def metrics_explain( + ctx: click.Context, + execution_id: str, +): + depth = ctx.obj[CTX_DEPTH] + domain = ctx.obj[CTX_DOMAIN] + project = ctx.obj[CTX_PROJECT] + + # retrieve remote + remote = get_and_save_remote_with_click_context(ctx, project, domain) + sync_client = remote.client + + # retrieve workflow execution metrics + workflow_execution_id = WorkflowExecutionIdentifier(project=project, domain=domain, name=execution_id) + + request = WorkflowExecutionGetMetricsRequest(id=workflow_execution_id, depth=depth) + response = sync_client.get_execution_metrics(request) + + # print execution spans + print( + "{:25s}{:25s}{:25s} {:>8s} {:s}".format( + "operation", "start_timestamp", "end_timestamp", "duration", "entity" + ) + ) + print("-" * 140) + + print_span(response.span, -1, "") + + +def print_span(span, indent, identifier): + start_time = datetime.fromtimestamp(span.start_time.seconds + span.start_time.nanos / 1e9) + end_time = datetime.fromtimestamp(span.end_time.seconds + span.end_time.nanos / 1e9) + + id_type = span.WhichOneof("id") + span_identifier = "" + + if id_type == "operation_id": + indent_str = "" + for i in range(indent): + indent_str += " " + + print( + "{:25s}{:25s}{:25s} {:7.2f}s {:s}{:s}".format( + span.operation_id, + start_time.strftime("%m-%d %H:%M:%S.%f"), + end_time.strftime("%m-%d %H:%M:%S.%f"), + (end_time - start_time).total_seconds(), + indent_str, + identifier, + ) + ) + + span_identifier = identifier + "/" + span.operation_id + else: + if id_type == "workflow_id": + span_identifier = "workflow/" + span.workflow_id.name + elif id_type == "node_id": + span_identifier = "node/" + span.node_id.node_id + elif id_type == "task_id": + span_identifier = "task/" + str(span.task_id.retry_attempt) + + for under_span in span.spans: + print_span(under_span, indent + 1, span_identifier) + + +metrics.add_command(metrics_dump) +metrics.add_command(metrics_explain) diff --git a/flytekit/flytekit/clis/sdk_in_container/package.py b/flytekit/flytekit/clis/sdk_in_container/package.py new file mode 100644 index 0000000000..c61b02a16d --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/package.py @@ -0,0 +1,141 @@ +import os + +import rich_click as click + +from flytekit.clis.helpers import display_help_with_error +from flytekit.clis.sdk_in_container import constants +from flytekit.configuration import ( + DEFAULT_RUNTIME_PYTHON_INTERPRETER, + FastSerializationSettings, + ImageConfig, + SerializationSettings, +) +from flytekit.interaction.click_types import key_value_callback +from flytekit.tools.repo import NoSerializableEntitiesError, serialize_and_package + + +@click.command("package") +@click.option( + "-i", + "--image", + "image_config", + required=False, + multiple=True, + type=click.UNPROCESSED, + callback=ImageConfig.validate_image, + help="A fully qualified tag for an docker image, for example ``somedocker.com/myimage:someversion123``. This is a " + "multi-option and can be of the form ``--image xyz.io/docker:latest" + " --image my_image=xyz.io/docker2:latest``. Note, the ``name=image_uri``. The name is optional, if not " + "provided the image will be used as the default image. All the names have to be unique, and thus " + "there can only be one ``--image`` option with no name.", +) +@click.option( + "-s", + "--source", + required=False, + type=click.Path(exists=True, file_okay=False, readable=True, resolve_path=True, allow_dash=True), + default=".", + help="Local filesystem path to the root of the package.", +) +@click.option( + "-o", + "--output", + required=False, + type=click.Path(dir_okay=False, writable=True, resolve_path=True, allow_dash=True), + default="flyte-package.tgz", + help="Filesystem path to the source of the Python package (from where the pkgs will start).", +) +@click.option( + "--fast", + is_flag=True, + default=False, + required=False, + help="This flag enables fast packaging, that allows `no container build` deploys of flyte workflows and tasks. " + "Note this needs additional configuration, refer to the docs.", +) +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + required=False, + help="This flag enables overriding existing output files. If not specified, package will exit with an error," + " when an output file already exists.", +) +@click.option( + "-p", + "--python-interpreter", + default=DEFAULT_RUNTIME_PYTHON_INTERPRETER, + required=False, + help="Use this to override the default location of the in-container python interpreter that will be used by " + "Flyte to load your program. This is usually where you install flytekit within the container.", +) +@click.option( + "-d", + "--in-container-source-path", + required=False, + type=str, + default="/root", + help="Filesystem path to where the code is copied into within the Dockerfile. look for ``COPY . /root`` like command.", +) +@click.option( + "--deref-symlinks", + default=False, + is_flag=True, + help="Enables symlink dereferencing when packaging files in fast registration", +) +@click.option( + "--env", + "--envvars", + required=False, + multiple=True, + type=str, + callback=key_value_callback, + help="Environment variables to set in the container, of the format `ENV_NAME=ENV_VALUE`", +) +@click.pass_context +def package( + ctx, + image_config, + source, + output, + force, + fast, + in_container_source_path, + python_interpreter, + deref_symlinks, + env, +): + """ + This command produces a Flyte backend registrable package of all entities in Flyte. + For tasks, one pb file is produced for each task, representing one TaskTemplate object. + For workflows, one pb file is produced for each workflow, representing a WorkflowClosure object. The closure + object contains the WorkflowTemplate, along with the relevant tasks for that workflow. + This serialization step will set the name of the tasks to the fully qualified name of the task function. + """ + if os.path.exists(output) and not force: + raise click.BadParameter( + click.style( + f"Output file {output} already exists, specify -f to override.", + fg="red", + ) + ) + + serialization_settings = SerializationSettings( + image_config=image_config, + fast_serialization_settings=FastSerializationSettings( + enabled=fast, + destination_dir=in_container_source_path, + ), + python_interpreter=python_interpreter, + env=env, + ) + + pkgs = ctx.obj[constants.CTX_PACKAGES] + if not pkgs: + display_help_with_error(ctx, "No packages to scan for flyte entities. Aborting!") + + try: + serialize_and_package(pkgs, serialization_settings, source, output, fast, deref_symlinks) + except NoSerializableEntitiesError: + click.secho(f"No flyte objects found in packages {pkgs}", fg="yellow") diff --git a/flytekit/flytekit/clis/sdk_in_container/pyflyte.py b/flytekit/flytekit/clis/sdk_in_container/pyflyte.py new file mode 100644 index 0000000000..2a52c6adf7 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/pyflyte.py @@ -0,0 +1,102 @@ +import os +import typing + +import rich_click as click + +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.fetch import fetch +from flytekit.clis.sdk_in_container.get import get +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.metrics import metrics +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.clis.sdk_in_container.utils import ErrorHandlingCommand, validate_package +from flytekit.clis.version import info +from flytekit.configuration.file import FLYTECTL_CONFIG_ENV_VAR, FLYTECTL_CONFIG_ENV_VAR_OVERRIDE +from flytekit.configuration.internal import LocalSDK +from flytekit.configuration.plugin import get_plugin +from flytekit.loggers import logger + + +@click.group("pyflyte", invoke_without_command=True, cls=ErrorHandlingCommand) +@click.option( + "-v", + "--verbose", + required=False, + help="Show verbose messages and exception traces", + count=True, + default=0, + type=int, +) +@click.option( + "-k", + "--pkgs", + required=False, + multiple=True, + callback=validate_package, + help="Dot-delineated python packages to operate on. Multiple may be specified (can use commas, or specify the " + "switch multiple times. Please note that this " + "option will override the option specified in the configuration file, or environment variable", +) +@click.option( + "-c", + "--config", + required=False, + type=str, + help="Path to config file for use within container", +) +@click.pass_context +def main(ctx, pkgs: typing.List[str], config: str, verbose: int): + """ + Entrypoint for all the user commands. + """ + ctx.obj = dict() + + # Handle package management - get from the command line, the environment variables, then the config file. + pkgs = pkgs or LocalSDK.WORKFLOW_PACKAGES.read() or [] + if config: + ctx.obj[CTX_CONFIG_FILE] = config + cfg = configuration.ConfigFile(config) + # Set here so that if someone has Config.auto() in their user code, the config here will get used. + if FLYTECTL_CONFIG_ENV_VAR in os.environ: + logger.info( + f"Config file arg {config} will override env var {FLYTECTL_CONFIG_ENV_VAR}: {os.environ[FLYTECTL_CONFIG_ENV_VAR]}" + ) + os.environ[FLYTECTL_CONFIG_ENV_VAR_OVERRIDE] = config + if not pkgs: + pkgs = LocalSDK.WORKFLOW_PACKAGES.read(cfg) + if pkgs is None: + pkgs = [] + + ctx.obj[CTX_PACKAGES] = pkgs + ctx.obj[CTX_VERBOSE] = verbose + + +main.add_command(serialize) +main.add_command(package) +main.add_command(local_cache) +main.add_command(init) +main.add_command(run) +main.add_command(register) +main.add_command(backfill) +main.add_command(serve) +main.add_command(build) +main.add_command(metrics) +main.add_command(launchplan) +main.add_command(fetch) +main.add_command(info) +main.add_command(get) +main.epilog + +get_plugin().configure_pyflyte_cli(main) + +if __name__ == "__main__": + main() diff --git a/flytekit/flytekit/clis/sdk_in_container/register.py b/flytekit/flytekit/clis/sdk_in_container/register.py new file mode 100644 index 0000000000..45e41efe47 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/register.py @@ -0,0 +1,202 @@ +import os +import typing + +import rich_click as click + +from flytekit.clis.helpers import display_help_with_error +from flytekit.clis.sdk_in_container import constants +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context, patch_image_config +from flytekit.clis.sdk_in_container.utils import domain_option_dec, project_option_dec +from flytekit.configuration import ImageConfig +from flytekit.configuration.default_images import DefaultImages +from flytekit.interaction.click_types import key_value_callback +from flytekit.loggers import logger +from flytekit.tools import repo + +_register_help = """ +This command is similar to ``package`` but instead of producing a zip file, all your Flyte entities are compiled, +and then sent to the backend specified by your config file. Think of this as combining the ``pyflyte package`` +and the ``flytectl register`` steps in one command. This is why you see switches you'd normally use with flytectl +like service account here. + +Note: This command runs "fast" register by default. +This means that a zip is created from the detected root of the packages given and uploaded. Just like with +``pyflyte run``, tasks registered from this command will download and unzip that code package before running. + +Note: This command only works on regular Python packages, not namespace packages. When determining +the root of your project, it finds the first folder that does not have a ``__init__.py`` file. +""" + + +@click.command("register", help=_register_help) +@project_option_dec +@domain_option_dec +@click.option( + "-i", + "--image", + "image_config", + required=False, + multiple=True, + type=click.UNPROCESSED, + callback=ImageConfig.validate_image, + default=[DefaultImages.default_image()], + help="A fully qualified tag for an docker image, for example ``somedocker.com/myimage:someversion123``. This is a " + "multi-option and can be of the form ``--image xyz.io/docker:latest" + " --image my_image=xyz.io/docker2:latest``. Note, the ``name=image_uri``. The name is optional, if not " + "provided the image will be used as the default image. All the names have to be unique, and thus " + "there can only be one ``--image`` option with no name.", +) +@click.option( + "-o", + "--output", + required=False, + type=click.Path(dir_okay=True, file_okay=False, writable=True, resolve_path=True), + default=None, + help="Directory to write the output zip file containing the protobuf definitions", +) +@click.option( + "-D", + "--destination-dir", + required=False, + type=str, + default="/root", + help="Directory inside the image where the tar file containing the code will be copied to", +) +@click.option( + "--service-account", + required=False, + type=str, + default="", + help="Service account used when creating launch plans", +) +@click.option( + "--raw-data-prefix", + required=False, + type=str, + default="", + help="Raw output data prefix when creating launch plans, where offloaded data will be stored", +) +@click.option( + "-v", + "--version", + required=False, + type=str, + help="Version the package or module is registered with", +) +@click.option( + "--deref-symlinks", + default=False, + is_flag=True, + help="Enables symlink dereferencing when packaging files in fast registration", +) +@click.option( + "--non-fast", + default=False, + is_flag=True, + help="Skip zipping and uploading the package", +) +@click.option( + "--dry-run", + default=False, + is_flag=True, + help="Execute registration in dry-run mode. Skips actual registration to remote", +) +@click.option( + "--activate-launchplans", + "--activate-launchplan", + default=False, + is_flag=True, + help="Activate newly registered Launchplans. This operation deactivates previous versions of Launchplans.", +) +@click.option( + "--env", + "--envvars", + required=False, + multiple=True, + type=str, + callback=key_value_callback, + help="Environment variables to set in the container, of the format `ENV_NAME=ENV_VALUE`", +) +@click.option( + "--skip-errors", + "--skip-error", + default=False, + is_flag=True, + help="Skip errors during registration. This is useful when registering multiple packages and you want to skip " + "errors for some packages.", +) +@click.argument("package-or-module", type=click.Path(exists=True, readable=True, resolve_path=True), nargs=-1) +@click.pass_context +def register( + ctx: click.Context, + project: str, + domain: str, + image_config: ImageConfig, + output: str, + destination_dir: str, + service_account: str, + raw_data_prefix: str, + version: typing.Optional[str], + deref_symlinks: bool, + non_fast: bool, + package_or_module: typing.Tuple[str], + dry_run: bool, + activate_launchplans: bool, + env: typing.Optional[typing.Dict[str, str]], + skip_errors: bool, +): + """ + see help + """ + pkgs = ctx.obj[constants.CTX_PACKAGES] + if not pkgs: + logger.debug("No pkgs") + if pkgs: + raise ValueError("Unimplemented, just specify pkgs like folder/files as args at the end of the command") + + if non_fast and not version: + raise ValueError("Version is a required parameter in case --non-fast is specified.") + + if len(package_or_module) == 0: + display_help_with_error( + ctx, + "Missing argument 'PACKAGE_OR_MODULE...', at least one PACKAGE_OR_MODULE is required but multiple can be passed", + ) + + # Use extra images in the config file if that file exists + config_file = ctx.obj.get(constants.CTX_CONFIG_FILE) + if config_file: + image_config = patch_image_config(config_file, image_config) + + click.secho( + f"Running pyflyte register from {os.getcwd()} " + f"with images {image_config} " + f"and image destination folder {destination_dir} " + f"on {len(package_or_module)} package(s) {package_or_module}", + dim=True, + ) + + # Create and save FlyteRemote, + remote = get_and_save_remote_with_click_context(ctx, project, domain, data_upload_location="flyte://data") + click.secho(f"Registering against {remote.config.platform.endpoint}") + try: + repo.register( + project, + domain, + image_config, + output, + destination_dir, + service_account, + raw_data_prefix, + version, + deref_symlinks, + fast=not non_fast, + package_or_module=package_or_module, + remote=remote, + env=env, + dry_run=dry_run, + activate_launchplans=activate_launchplans, + skip_errors=skip_errors, + ) + except Exception as e: + raise e diff --git a/flytekit/flytekit/clis/sdk_in_container/run.py b/flytekit/flytekit/clis/sdk_in_container/run.py new file mode 100644 index 0000000000..bd87f7b64b --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/run.py @@ -0,0 +1,885 @@ +import asyncio +import importlib +import inspect +import json +import os +import pathlib +import tempfile +import typing +from dataclasses import dataclass, field, fields +from typing import cast, get_args + +import rich_click as click +from dataclasses_json import DataClassJsonMixin +from rich.progress import Progress + +from flytekit import Annotations, FlyteContext, FlyteContextManager, Labels, Literal +from flytekit.clis.sdk_in_container.helpers import patch_image_config +from flytekit.clis.sdk_in_container.utils import ( + PyFlyteParams, + domain_option, + get_option_from_metadata, + make_click_option_field, + pretty_print_exception, + project_option, +) +from flytekit.configuration import DefaultImages, FastSerializationSettings, ImageConfig, SerializationSettings +from flytekit.configuration.plugin import get_plugin +from flytekit.core import context_manager +from flytekit.core.base_task import PythonTask +from flytekit.core.data_persistence import FileAccessProvider +from flytekit.core.type_engine import TypeEngine +from flytekit.core.workflow import PythonFunctionWorkflow, WorkflowBase +from flytekit.exceptions.system import FlyteSystemException +from flytekit.interaction.click_types import FlyteLiteralConverter, key_value_callback +from flytekit.interaction.string_literals import literal_string_repr +from flytekit.loggers import logger +from flytekit.models import security +from flytekit.models.common import RawOutputDataConfig +from flytekit.models.interface import Parameter, Variable +from flytekit.models.types import SimpleType +from flytekit.remote import FlyteLaunchPlan, FlyteRemote, FlyteTask, FlyteWorkflow, remote_fs +from flytekit.remote.executions import FlyteWorkflowExecution +from flytekit.tools import module_loader +from flytekit.tools.script_mode import _find_project_root, compress_scripts +from flytekit.tools.translator import Options + + +@dataclass +class RunLevelComputedParams: + """ + This class is used to store the computed parameters that are used to run a workflow / task / launchplan. + Computed parameters are created during the execution + """ + + project_root: typing.Optional[str] = None + module: typing.Optional[str] = None + temp_file_name: typing.Optional[str] = None # Used to store the temporary location of the file downloaded + + +@dataclass +class RunLevelParams(PyFlyteParams): + """ + This class is used to store the parameters that are used to run a workflow / task / launchplan. + """ + + project: str = make_click_option_field(project_option) + domain: str = make_click_option_field(domain_option) + destination_dir: str = make_click_option_field( + click.Option( + param_decls=["--destination-dir", "destination_dir"], + required=False, + type=str, + default=".", + show_default=True, + help="Directory inside the image where the tar file containing the code will be copied to", + ) + ) + copy_all: bool = make_click_option_field( + click.Option( + param_decls=["--copy-all", "copy_all"], + required=False, + is_flag=True, + default=False, + show_default=True, + help="Copy all files in the source root directory to the destination directory", + ) + ) + image_config: ImageConfig = make_click_option_field( + click.Option( + param_decls=["-i", "--image", "image_config"], + required=False, + multiple=True, + type=click.UNPROCESSED, + callback=ImageConfig.validate_image, + default=[DefaultImages.default_image()], + show_default=True, + help="Image used to register and run.", + ) + ) + service_account: str = make_click_option_field( + click.Option( + param_decls=["--service-account", "service_account"], + required=False, + type=str, + default="", + help="Service account used when executing this workflow", + ) + ) + wait_execution: bool = make_click_option_field( + click.Option( + param_decls=["--wait-execution", "wait_execution"], + required=False, + is_flag=True, + default=False, + show_default=True, + help="Whether to wait for the execution to finish", + ) + ) + dump_snippet: bool = make_click_option_field( + click.Option( + param_decls=["--dump-snippet", "dump_snippet"], + required=False, + is_flag=True, + default=False, + show_default=True, + help="Whether to dump a code snippet instructing how to load the workflow execution using flyteremote", + ) + ) + overwrite_cache: bool = make_click_option_field( + click.Option( + param_decls=["--overwrite-cache", "overwrite_cache"], + required=False, + is_flag=True, + default=False, + show_default=True, + help="Whether to overwrite the cache if it already exists", + ) + ) + envvars: typing.Dict[str, str] = make_click_option_field( + click.Option( + param_decls=["--envvars", "--env"], + required=False, + multiple=True, + type=str, + show_default=True, + callback=key_value_callback, + help="Environment variables to set in the container, of the format `ENV_NAME=ENV_VALUE`", + ) + ) + tags: typing.List[str] = make_click_option_field( + click.Option( + param_decls=["--tags", "--tag"], + required=False, + multiple=True, + type=str, + show_default=True, + help="Tags to set for the execution", + ) + ) + name: str = make_click_option_field( + click.Option( + param_decls=["--name"], + required=False, + type=str, + show_default=True, + help="Name to assign to this execution", + ) + ) + labels: typing.Dict[str, str] = make_click_option_field( + click.Option( + param_decls=["--labels", "--label"], + required=False, + multiple=True, + type=str, + show_default=True, + callback=key_value_callback, + help="Labels to be attached to the execution of the format `label_key=label_value`.", + ) + ) + annotations: typing.Dict[str, str] = make_click_option_field( + click.Option( + param_decls=["--annotations", "--annotation"], + required=False, + multiple=True, + type=str, + show_default=True, + callback=key_value_callback, + help="Annotations to be attached to the execution of the format `key=value`.", + ) + ) + raw_output_data_prefix: str = make_click_option_field( + click.Option( + param_decls=["--raw-output-data-prefix", "--raw-data-prefix"], + required=False, + type=str, + show_default=True, + help="File Path prefix to store raw output data." + " Examples are file://, s3://, gs:// etc as supported by fsspec." + " If not specified, raw data will be stored in default configured location in remote of locally" + " to temp file system." + + click.style( + "Note, this is not metadata, but only the raw data location " + "used to store Flytefile, Flytedirectory, Structuredataset," + " dataframes" + ), + ) + ) + max_parallelism: int = make_click_option_field( + click.Option( + param_decls=["--max-parallelism"], + required=False, + type=int, + show_default=True, + help="Number of nodes of a workflow that can be executed in parallel. If not specified," + " project/domain defaults are used. If 0 then it is unlimited.", + ) + ) + disable_notifications: bool = make_click_option_field( + click.Option( + param_decls=["--disable-notifications"], + required=False, + is_flag=True, + default=False, + show_default=True, + help="Should notifications be disabled for this execution.", + ) + ) + remote: bool = make_click_option_field( + click.Option( + param_decls=["-r", "--remote"], + required=False, + is_flag=True, + default=False, + is_eager=True, + show_default=True, + help="Whether to register and run the workflow on a Flyte deployment", + ) + ) + limit: int = make_click_option_field( + click.Option( + param_decls=["--limit", "limit"], + required=False, + type=int, + default=50, + hidden=True, + show_default=True, + help="Use this to limit number of entities to fetch", + ) + ) + cluster_pool: str = make_click_option_field( + click.Option( + param_decls=["--cluster-pool", "cluster_pool"], + required=False, + type=str, + default="", + help="Assign newly created execution to a given cluster pool", + ) + ) + computed_params: RunLevelComputedParams = field(default_factory=RunLevelComputedParams) + _remote: typing.Optional[FlyteRemote] = None + + def remote_instance(self) -> FlyteRemote: + if self._remote is None: + data_upload_location = None + if self.is_remote: + data_upload_location = remote_fs.REMOTE_PLACEHOLDER + self._remote = get_plugin().get_remote(self.config_file, self.project, self.domain, data_upload_location) + return self._remote + + @property + def is_remote(self) -> bool: + return self.remote + + @classmethod + def from_dict(cls, d: typing.Dict[str, typing.Any]) -> "RunLevelParams": + return cls(**d) + + @classmethod + def options(cls) -> typing.List[click.Option]: + """ + Return the set of base parameters added to every pyflyte run workflow subcommand. + """ + return [get_option_from_metadata(f.metadata) for f in fields(cls) if f.metadata] + + +def load_naive_entity(module_name: str, entity_name: str, project_root: str) -> typing.Union[WorkflowBase, PythonTask]: + """ + Load the workflow of a script file. + N.B.: it assumes that the file is self-contained, in other words, there are no relative imports. + """ + flyte_ctx_builder = context_manager.FlyteContextManager.current_context().new_builder() + with context_manager.FlyteContextManager.with_context(flyte_ctx_builder): + with module_loader.add_sys_path(project_root): + importlib.import_module(module_name) + return module_loader.load_object_from_module(f"{module_name}.{entity_name}") + + +def dump_flyte_remote_snippet(execution: FlyteWorkflowExecution, project: str, domain: str): + click.secho( + f""" +In order to have programmatic access to the execution, use the following snippet: + +from flytekit.configuration import Config +from flytekit.remote import FlyteRemote +remote = FlyteRemote(Config.auto(), default_project="{project}", default_domain="{domain}") +exec = remote.fetch_execution(name="{execution.id.name}") +remote.sync(exec) +print(exec.outputs) + """ + ) + + +class Entities(typing.NamedTuple): + """ + NamedTuple to group all entities in a file + """ + + workflows: typing.List[str] + tasks: typing.List[str] + + def all(self) -> typing.List[str]: + e = [] + e.extend(self.workflows) + e.extend(self.tasks) + return e + + +def get_entities_in_file(filename: pathlib.Path, should_delete: bool) -> Entities: + """ + Returns a list of flyte workflow names and list of Flyte tasks in a file. + """ + flyte_ctx = context_manager.FlyteContextManager.current_context().new_builder() + module_name = os.path.splitext(os.path.relpath(filename))[0].replace(os.path.sep, ".") + with context_manager.FlyteContextManager.with_context(flyte_ctx): + with module_loader.add_sys_path(os.getcwd()): + importlib.import_module(module_name) + + workflows = [] + tasks = [] + module = importlib.import_module(module_name) + for name in dir(module): + o = module.__dict__[name] + if isinstance(o, WorkflowBase): + workflows.append(name) + elif isinstance(o, PythonTask): + tasks.append(name) + + if should_delete and os.path.exists(filename): + os.remove(filename) + return Entities(workflows, tasks) + + +def to_click_option( + ctx: click.Context, + flyte_ctx: FlyteContext, + input_name: str, + literal_var: Variable, + python_type: typing.Type, + default_val: typing.Any, + required: bool, +) -> click.Option: + """ + This handles converting workflow input types to supported click parameters with callbacks to initialize + the input values to their expected types. + """ + run_level_params: RunLevelParams = ctx.obj + + literal_converter = FlyteLiteralConverter( + flyte_ctx, + literal_type=literal_var.type, + python_type=python_type, + is_remote=run_level_params.is_remote, + ) + + if literal_converter.is_bool() and not default_val: + default_val = False + + description_extra = "" + 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() + if literal_var.type.metadata: + description_extra = f": {json.dumps(literal_var.type.metadata)}" + + return click.Option( + param_decls=[f"--{input_name}"], + type=literal_converter.click_type, + is_flag=literal_converter.is_bool(), + default=default_val, + show_default=True, + required=required, + help=literal_var.description + description_extra, + callback=literal_converter.convert, + ) + + +def options_from_run_params(run_level_params: RunLevelParams) -> Options: + return Options( + labels=Labels(run_level_params.labels) if run_level_params.labels else None, + annotations=Annotations(run_level_params.annotations) if run_level_params.annotations else None, + raw_output_data_config=RawOutputDataConfig(output_location_prefix=run_level_params.raw_output_data_prefix) + if run_level_params.raw_output_data_prefix + else None, + max_parallelism=run_level_params.max_parallelism, + disable_notifications=run_level_params.disable_notifications, + security_context=security.SecurityContext( + run_as=security.Identity(k8s_service_account=run_level_params.service_account) + ) + if run_level_params.service_account + else None, + notifications=[], + ) + + +def run_remote( + remote: FlyteRemote, + entity: typing.Union[FlyteWorkflow, FlyteTask, FlyteLaunchPlan], + project: str, + domain: str, + inputs: typing.Dict[str, typing.Any], + run_level_params: RunLevelParams, + type_hints: typing.Optional[typing.Dict[str, typing.Type]] = None, +): + """ + Helper method that executes the given remote FlyteLaunchplan, FlyteWorkflow or FlyteTask + """ + + execution = remote.execute( + entity, + inputs=inputs, + project=project, + domain=domain, + execution_name=run_level_params.name, + wait=run_level_params.wait_execution, + options=options_from_run_params(run_level_params), + type_hints=type_hints, + overwrite_cache=run_level_params.overwrite_cache, + envs=run_level_params.envvars, + tags=run_level_params.tags, + cluster_pool=run_level_params.cluster_pool, + ) + + console_url = remote.generate_console_url(execution) + s = ( + click.style("\n[✔] ", fg="green") + + "Go to " + + click.style(console_url, fg="cyan") + + " to see execution in the console." + ) + click.echo(s) + + if run_level_params.dump_snippet: + dump_flyte_remote_snippet(execution, project, domain) + + +def _update_flyte_context(params: RunLevelParams) -> FlyteContext.Builder: + # Update the flyte context for the local execution. + ctx = FlyteContextManager.current_context() + output_prefix = params.raw_output_data_prefix + if not ctx.file_access.is_remote(output_prefix): + return ctx.current_context().new_builder() + + file_access = FileAccessProvider( + local_sandbox_dir=tempfile.mkdtemp(prefix="flyte"), raw_output_prefix=output_prefix + ) + + # The task might run on a remote machine if raw_output_prefix is a remote path, + # so workflow file needs to be uploaded to the remote location to make pyflyte-fast-execute work. + if output_prefix and ctx.file_access.is_remote(output_prefix): + with tempfile.TemporaryDirectory() as tmp_dir: + archive_fname = pathlib.Path(os.path.join(tmp_dir, "script_mode.tar.gz")) + compress_scripts(params.computed_params.project_root, str(archive_fname), params.computed_params.module) + remote_dir = file_access.get_random_remote_directory() + remote_archive_fname = f"{remote_dir}/script_mode.tar.gz" + file_access.put_data(str(archive_fname), remote_archive_fname) + + ctx_builder = ctx.with_file_access(ctx.file_access).with_serialization_settings( + SerializationSettings( + source_root=params.computed_params.project_root, + image_config=params.image_config, + fast_serialization_settings=FastSerializationSettings( + enabled=True, + destination_dir=params.destination_dir, + distribution_location=remote_archive_fname, + ), + ) + ) + return ctx_builder.with_file_access(file_access) + + +def run_command(ctx: click.Context, entity: typing.Union[PythonFunctionWorkflow, PythonTask]): + """ + Returns a function that is used to implement WorkflowCommand and execute a flyte workflow. + """ + + def _run(*args, **kwargs): + """ + Click command function that is used to execute a flyte workflow from the given entity in the file. + """ + # By the time we get to this function, all the loading has already happened + + run_level_params: RunLevelParams = ctx.obj + logger.info(f"Running {entity.name} with {kwargs} and run_level_params {run_level_params}") + + click.secho(f"Running Execution on {'Remote' if run_level_params.is_remote else 'local'}.", fg="cyan") + try: + inputs = {} + for input_name, _ in entity.python_interface.inputs.items(): + inputs[input_name] = kwargs.get(input_name) + + if not run_level_params.is_remote: + with FlyteContextManager.with_context(_update_flyte_context(run_level_params)): + if run_level_params.envvars: + for env_var, value in run_level_params.envvars.items(): + os.environ[env_var] = value + if run_level_params.overwrite_cache: + os.environ["FLYTE_LOCAL_CACHE_OVERWRITE"] = "true" + output = entity(**inputs) + if inspect.iscoroutine(output): + # TODO: make eager mode workflows run with local-mode + output = asyncio.run(output) + click.echo(output) + return + + remote = run_level_params.remote_instance() + config_file = run_level_params.config_file + + image_config = run_level_params.image_config + image_config = patch_image_config(config_file, image_config) + + with context_manager.FlyteContextManager.with_context(remote.context.new_builder()): + remote_entity = remote.register_script( + entity, + project=run_level_params.project, + domain=run_level_params.domain, + image_config=image_config, + destination_dir=run_level_params.destination_dir, + source_path=run_level_params.computed_params.project_root, + module_name=run_level_params.computed_params.module, + copy_all=run_level_params.copy_all, + ) + + run_remote( + remote, + remote_entity, + run_level_params.project, + run_level_params.domain, + inputs, + run_level_params, + type_hints=entity.python_interface.inputs, + ) + finally: + if run_level_params.computed_params.temp_file_name: + os.remove(run_level_params.computed_params.temp_file_name) + + return _run + + +class DynamicEntityLaunchCommand(click.RichCommand): + """ + This is a dynamic command that is created for each launch plan. This is used to execute a launch plan. + It will fetch the launch plan from remote and create parameters from all the inputs of the launch plan. + """ + + LP_LAUNCHER = "lp" + TASK_LAUNCHER = "task" + + def __init__(self, name: str, h: str, entity_name: str, launcher: str, **kwargs): + super().__init__(name=name, help=h, **kwargs) + self._entity_name = entity_name + self._launcher = launcher + self._entity = None + + def _fetch_entity(self, ctx: click.Context) -> typing.Union[FlyteLaunchPlan, FlyteTask]: + if self._entity: + return self._entity + run_level_params: RunLevelParams = ctx.obj + r = run_level_params.remote_instance() + if self._launcher == self.LP_LAUNCHER: + entity = r.fetch_launch_plan(run_level_params.project, run_level_params.domain, self._entity_name) + else: + entity = r.fetch_task(run_level_params.project, run_level_params.domain, self._entity_name) + self._entity = entity + return entity + + def _get_params( + self, + ctx: click.Context, + inputs: typing.Dict[str, Variable], + native_inputs: typing.Dict[str, type], + fixed: typing.Optional[typing.Dict[str, Literal]] = None, + defaults: typing.Optional[typing.Dict[str, Parameter]] = None, + ) -> typing.List["click.Parameter"]: + params = [] + flyte_ctx = context_manager.FlyteContextManager.current_context() + for name, var in inputs.items(): + if fixed and name in fixed: + continue + required = True + default_val = None + if defaults and name in defaults: + if not defaults[name].required: + required = False + default_val = literal_string_repr(defaults[name].default) if defaults[name].default else None + params.append(to_click_option(ctx, flyte_ctx, name, var, native_inputs[name], default_val, required)) + return params + + def get_params(self, ctx: click.Context) -> typing.List["click.Parameter"]: + if not self.params: + self.params = [] + entity = self._fetch_entity(ctx) + if entity.interface: + if entity.interface.inputs: + types = TypeEngine.guess_python_types(entity.interface.inputs) + if isinstance(entity, FlyteLaunchPlan): + self.params = self._get_params( + ctx, + entity.interface.inputs, + types, + entity.fixed_inputs.literals, + entity.default_inputs.parameters, + ) + else: + self.params = self._get_params(ctx, entity.interface.inputs, types) + + return super().get_params(ctx) + + def invoke(self, ctx: click.Context) -> typing.Any: + """ + Default or None values should be ignored. Only values that are provided by the user should be passed to the + remote execution. + """ + run_level_params: RunLevelParams = ctx.obj + r = run_level_params.remote_instance() + entity = self._fetch_entity(ctx) + run_remote( + r, + entity, + run_level_params.project, + run_level_params.domain, + ctx.params, + run_level_params, + type_hints=entity.python_interface.inputs if entity.python_interface else None, + ) + + +class RemoteEntityGroup(click.RichGroup): + """ + click multicommand that retrieves launchplans from a remote flyte instance and executes them. + """ + + LAUNCHPLAN_COMMAND = "remote-launchplan" + WORKFLOW_COMMAND = "remote-workflow" + TASK_COMMAND = "remote-task" + + def __init__(self, command_name: str): + super().__init__( + name=command_name, + help=f"Retrieve {command_name} from a remote flyte instance and execute them.", + params=[ + click.Option( + ["--limit", "limit"], + help=f"Limit the number of {command_name}'s to retrieve.", + default=50, + show_default=True, + ) + ], + ) + self._command_name = command_name + self._entities = [] + + def _get_entities(self, r: FlyteRemote, project: str, domain: str, limit: int) -> typing.List[str]: + """ + Retreieves the right entities from the remote flyte instance. + """ + if self._command_name == self.LAUNCHPLAN_COMMAND: + lps = r.client.list_launch_plan_ids_paginated(project=project, domain=domain, limit=limit) + return [l.name for l in lps[0]] + elif self._command_name == self.WORKFLOW_COMMAND: + wfs = r.client.list_workflow_ids_paginated(project=project, domain=domain, limit=limit) + return [w.name for w in wfs[0]] + elif self._command_name == self.TASK_COMMAND: + tasks = r.client.list_task_ids_paginated(project=project, domain=domain, limit=limit) + return [t.name for t in tasks[0]] + return [] + + def list_commands(self, ctx): + if self._entities or ctx.obj is None: + return self._entities + + run_level_params: RunLevelParams = ctx.obj + r = run_level_params.remote_instance() + progress = Progress(transient=True) + task = progress.add_task(f"[cyan]Gathering [{run_level_params.limit}] remote LaunchPlans...", total=None) + with progress: + progress.start_task(task) + try: + self._entities = self._get_entities( + r, run_level_params.project, run_level_params.domain, run_level_params.limit + ) + return self._entities + except FlyteSystemException as e: + pretty_print_exception(e) + return [] + + def get_command(self, ctx, name): + if self._command_name in [self.LAUNCHPLAN_COMMAND, self.WORKFLOW_COMMAND]: + return DynamicEntityLaunchCommand( + name=name, + h=f"Execute a {self._command_name}.", + entity_name=name, + launcher=DynamicEntityLaunchCommand.LP_LAUNCHER, + ) + return DynamicEntityLaunchCommand( + name=name, + h=f"Execute a {self._command_name}.", + entity_name=name, + launcher=DynamicEntityLaunchCommand.TASK_LAUNCHER, + ) + + +class WorkflowCommand(click.RichGroup): + """ + 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) + + ctx = context_manager.FlyteContextManager.current_context() + if ctx.file_access.is_remote(filename): + local_path = os.path.join(os.path.curdir, filename.rsplit("/", 1)[1]) + ctx.file_access.download(filename, local_path) + self._filename = pathlib.Path(local_path).resolve() + self._should_delete = True + else: + self._filename = pathlib.Path(filename).resolve() + self._should_delete = False + self._entities = None + + def list_commands(self, ctx): + if self._entities: + return self._entities.all() + entities = get_entities_in_file(self._filename, self._should_delete) + self._entities = entities + return entities.all() + + def _create_command( + self, + ctx: click.Context, + entity_name: str, + run_level_params: RunLevelParams, + loaded_entity: typing.Any, + is_workflow: bool, + ): + """ + Delegate that creates the command for a given entity. + """ + + # If this is a remote execution, which we should know at this point, then create the remote object + r = run_level_params.remote_instance() + flyte_ctx = r.context + + # Add options for each of the workflow inputs + params = [] + for input_name, input_type_val in loaded_entity.python_interface.inputs_with_defaults.items(): + literal_var = loaded_entity.interface.inputs.get(input_name) + python_type, default_val = input_type_val + required = type(None) not in get_args(python_type) and default_val is None + params.append(to_click_option(ctx, flyte_ctx, input_name, literal_var, python_type, default_val, required)) + + entity_type = "Workflow" if is_workflow else "Task" + h = f"{click.style(entity_type, bold=True)} ({run_level_params.computed_params.module}.{entity_name})" + if loaded_entity.__doc__: + h = h + click.style(f"{loaded_entity.__doc__}", dim=True) + cmd = click.RichCommand( + name=entity_name, + params=params, + callback=run_command(ctx, loaded_entity), + help=h, + ) + return cmd + + 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: + """ + is_workflow = False + if self._entities: + is_workflow = exe_entity in self._entities.workflows + if not os.path.exists(self._filename): + raise ValueError(f"File {self._filename} does not exist") + 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) + + # Find the relative path for the filename relative to the root of the project. + # N.B.: by construction project_root will necessarily be an ancestor of the filename passed in as + # a parameter. + rel_path = self._filename.relative_to(project_root) + module = os.path.splitext(rel_path)[0].replace(os.path.sep, ".") + + run_level_params: RunLevelParams = ctx.obj + + # update computed params + run_level_params.computed_params.project_root = project_root + run_level_params.computed_params.module = module + + if self._should_delete: + run_level_params.computed_params.temp_file_name = self._filename + + entity = load_naive_entity(module, exe_entity, project_root) + + return self._create_command(ctx, exe_entity, run_level_params, entity, is_workflow) + + +class RunCommand(click.RichGroup): + """ + A click command group for registering and executing flyte workflows & tasks in a file. + """ + + _run_params: typing.Type[RunLevelParams] = RunLevelParams + + def __init__(self, *args, **kwargs): + if "params" not in kwargs: + params = self._run_params.options() + kwargs["params"] = params + super().__init__(*args, **kwargs) + self._files = [] + + def list_commands(self, ctx, add_remote: bool = True): + if self._files: + return self._files + self._files = [str(p) for p in pathlib.Path(".").glob("*.py") if str(p) != "__init__.py"] + self._files = sorted(self._files) + if add_remote: + self._files = self._files + [ + RemoteEntityGroup.LAUNCHPLAN_COMMAND, + RemoteEntityGroup.WORKFLOW_COMMAND, + RemoteEntityGroup.TASK_COMMAND, + ] + return self._files + + def get_command(self, ctx, filename): + if ctx.obj is None: + ctx.obj = {} + if not isinstance(ctx.obj, self._run_params): + params = {} + params.update(ctx.params) + params.update(ctx.obj) + ctx.obj = self._run_params.from_dict(params) + if filename == RemoteEntityGroup.LAUNCHPLAN_COMMAND: + return RemoteEntityGroup(RemoteEntityGroup.LAUNCHPLAN_COMMAND) + elif filename == RemoteEntityGroup.WORKFLOW_COMMAND: + return RemoteEntityGroup(RemoteEntityGroup.WORKFLOW_COMMAND) + elif filename == RemoteEntityGroup.TASK_COMMAND: + return RemoteEntityGroup(RemoteEntityGroup.TASK_COMMAND) + return WorkflowCommand(filename, name=filename, help=f"Run a [workflow|task] from {filename}") + + +_run_help = """ +This command can execute either a workflow or a task from the command line, allowing for fully self-contained scripts. +Tasks and workflows can be imported from other files. + +Note: This command is compatible with regular Python packages, but not with namespace packages. +When determining the root of your project, it identifies the first folder without an ``__init__.py`` file. +""" + +run = RunCommand( + name="run", + help=_run_help, +) diff --git a/flytekit/flytekit/clis/sdk_in_container/serialize.py b/flytekit/flytekit/clis/sdk_in_container/serialize.py new file mode 100644 index 0000000000..778f8f6a08 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/serialize.py @@ -0,0 +1,221 @@ +import os +import sys +import typing +from enum import Enum as _Enum + +import rich_click as click + +from flytekit.clis.sdk_in_container import constants +from flytekit.clis.sdk_in_container.constants import CTX_PACKAGES +from flytekit.configuration import FastSerializationSettings, ImageConfig, SerializationSettings +from flytekit.exceptions.scopes import system_entry_point +from flytekit.interaction.click_types import key_value_callback +from flytekit.tools.fast_registration import fast_package +from flytekit.tools.repo import serialize_to_folder + +CTX_IMAGE = "image" +CTX_LOCAL_SRC_ROOT = "local_source_root" +CTX_FLYTEKIT_VIRTUALENV_ROOT = "flytekit_virtualenv_root" +CTX_PYTHON_INTERPRETER = "python_interpreter" +CTX_ENV = "env" + + +class SerializationMode(_Enum): + DEFAULT = 0 + FAST = 1 + + +@system_entry_point +def serialize_all( + pkgs: typing.List[str] = None, + local_source_root: typing.Optional[str] = None, + folder: typing.Optional[str] = None, + mode: typing.Optional[SerializationMode] = None, + image_config: typing.Optional[ImageConfig] = None, + flytekit_virtualenv_root: typing.Optional[str] = None, + python_interpreter: typing.Optional[str] = None, + config_file: typing.Optional[str] = None, + env: typing.Optional[typing.Dict[str, str]] = None, +): + """ + This function will write to the folder specified the following protobuf types :: + flyteidl.admin.launch_plan_pb2.LaunchPlan + flyteidl.admin.workflow_pb2.WorkflowSpec + flyteidl.admin.task_pb2.TaskSpec + + These can be inspected by calling (in the launch plan case) :: + flyte-cli parse-proto -f filename.pb -p flyteidl.admin.launch_plan_pb2.LaunchPlan + + See :py:class:`flytekit.models.core.identifier.ResourceType` to match the trailing index in the file name with the + entity type. + :param pkgs: Dot-delimited Python packages/subpackages to look into for serialization. + :param local_source_root: Where to start looking for the code. + :param folder: Where to write the output protobuf files + :param mode: Regular vs fast + :param image_config: ImageConfig object to use + :param flytekit_virtualenv_root: The full path of the virtual env in the container. + """ + + if not (mode == SerializationMode.DEFAULT or mode == SerializationMode.FAST): + raise AssertionError(f"Unrecognized serialization mode: {mode}") + + serialization_settings = SerializationSettings( + image_config=image_config or ImageConfig.auto(config_file), + fast_serialization_settings=FastSerializationSettings( + enabled=mode == SerializationMode.FAST, + # TODO: if we want to move the destination dir as a serialization argument, we should initialize it here + ), + flytekit_virtualenv_root=flytekit_virtualenv_root, + python_interpreter=python_interpreter, + env=env, + ) + + serialize_to_folder(pkgs, serialization_settings, local_source_root, folder) + + +@click.group("serialize", cls=click.RichGroup) +@click.option( + "-i", + "--image", + "image_config", + required=False, + multiple=True, + type=click.UNPROCESSED, + callback=ImageConfig.validate_image, + help="A fully qualified tag for an docker image, for example ``somedocker.com/myimage:someversion123``. This is a " + "multi-option and can be of the form ``--image xyz.io/docker:latest" + " --image my_image=xyz.io/docker2:latest``. Note, the ``name=image_uri``. The name is optional, if not " + "provided the image will be used as the default image. All the names have to be unique, and thus " + "there can only be one ``--image`` option with no name.", +) +@click.option( + "--local-source-root", + required=False, + default=lambda: os.getcwd(), + help="Root dir for Python code containing workflow definitions to operate on when not the current working directory. " + "Optional when running ``pyflyte serialize`` in out-of-container-mode and your code lies outside of your working directory.", +) +@click.option( + "--in-container-config-path", + required=False, + help="This is where the configuration for your task lives inside the container. " + "The reason it needs to be a separate option is because this pyflyte utility cannot know where the Dockerfile " + "writes the config file to. Required for running ``pyflyte serialize`` in out-of-container-mode", +) +@click.option( + "--in-container-virtualenv-root", + required=False, + help="DEPRECATED: This flag is ignored! This is the root of the flytekit virtual env in your container. " + "The reason it needs to be a separate option is because this pyflyte utility cannot know where flytekit is " + "installed inside your container. Required for running `pyflyte serialize` in out of container mode when " + "your container installs the flytekit virtualenv outside of the default `/opt/venv`", +) +@click.option( + "--env", + "--envvars", + required=False, + multiple=True, + type=str, + callback=key_value_callback, + help="Environment variables to set in the container, of the format `ENV_NAME=ENV_VALUE`", +) +@click.pass_context +def serialize( + ctx, + image_config: ImageConfig, + local_source_root, + in_container_config_path, + in_container_virtualenv_root, + env: typing.Optional[typing.Dict[str, str]], +): + """ + This command produces protobufs for tasks and templates. + For tasks, one pb file is produced for each task, representing one TaskTemplate object. + For workflows, one pb file is produced for each workflow, representing a WorkflowClosure object. The closure + object contains the WorkflowTemplate, along with the relevant tasks for that workflow. In lieu of Admin, + this serialization step will set the URN of the tasks to the fully qualified name of the task function. + """ + ctx.obj[CTX_IMAGE] = image_config + ctx.obj[CTX_LOCAL_SRC_ROOT] = local_source_root + ctx.obj[CTX_ENV] = env + click.echo(f"Serializing Flyte elements with image {image_config}") + + if in_container_virtualenv_root: + ctx.obj[CTX_FLYTEKIT_VIRTUALENV_ROOT] = in_container_virtualenv_root + ctx.obj[CTX_PYTHON_INTERPRETER] = os.path.join(in_container_virtualenv_root, "/bin/python3") + else: + # For in container serialize we make sure to never accept an override the entrypoint path and determine it here + # instead. + import flytekit + + flytekit_install_loc = os.path.abspath(flytekit.__file__) + ctx.obj[CTX_FLYTEKIT_VIRTUALENV_ROOT] = os.path.dirname(flytekit_install_loc) + ctx.obj[CTX_PYTHON_INTERPRETER] = sys.executable + + +@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)) +@click.pass_context +def workflows(ctx, folder=None): + if folder: + click.echo(f"Writing output to {folder}") + + pkgs = ctx.obj[CTX_PACKAGES] + dir = ctx.obj[CTX_LOCAL_SRC_ROOT] + serialize_all( + pkgs, + dir, + folder, + SerializationMode.DEFAULT, + image_config=ctx.obj[CTX_IMAGE], + flytekit_virtualenv_root=ctx.obj[CTX_FLYTEKIT_VIRTUALENV_ROOT], + python_interpreter=ctx.obj[CTX_PYTHON_INTERPRETER], + config_file=ctx.obj.get(constants.CTX_CONFIG_FILE, None), + env=ctx.obj.get(CTX_ENV, None), + ) + + +@click.group("fast", cls=click.RichGroup) +@click.pass_context +def fast(ctx): + pass + + +@click.command("workflows", cls=click.RichCommand) +@click.option( + "--deref-symlinks", + default=False, + is_flag=True, + help="Enables symlink dereferencing when packaging files in fast registration", +) +@click.option("-f", "--folder", type=click.Path(exists=True)) +@click.pass_context +def fast_workflows(ctx, folder=None, deref_symlinks=False): + if folder: + click.echo(f"Writing output to {folder}") + + source_dir = ctx.obj[CTX_LOCAL_SRC_ROOT] + # Write using gzip + archive_fname = fast_package(source_dir, folder, deref_symlinks) + click.echo(f"Wrote compressed archive to {archive_fname}") + + pkgs = ctx.obj[CTX_PACKAGES] + dir = ctx.obj[CTX_LOCAL_SRC_ROOT] + serialize_all( + pkgs, + dir, + folder, + SerializationMode.FAST, + image_config=ctx.obj[CTX_IMAGE], + flytekit_virtualenv_root=ctx.obj[CTX_FLYTEKIT_VIRTUALENV_ROOT], + python_interpreter=ctx.obj[CTX_PYTHON_INTERPRETER], + config_file=ctx.obj.get(constants.CTX_CONFIG_FILE, None), + env=ctx.obj.get(CTX_ENV, None), + ) + + +fast.add_command(fast_workflows) +serialize.add_command(workflows) +serialize.add_command(fast) diff --git a/flytekit/flytekit/clis/sdk_in_container/serve.py b/flytekit/flytekit/clis/sdk_in_container/serve.py new file mode 100644 index 0000000000..87f008b084 --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/serve.py @@ -0,0 +1,71 @@ +from concurrent import futures + +import rich_click as click +from flyteidl.service.agent_pb2_grpc import ( + add_AgentMetadataServiceServicer_to_server, + add_AsyncAgentServiceServicer_to_server, +) +from grpc import aio + + +@click.group("serve") +@click.pass_context +def serve(ctx: click.Context): + """ + Start the specific service. + """ + pass + + +@serve.command() +@click.option( + "--port", + default="8000", + is_flag=False, + type=int, + help="Grpc port for the agent 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 agent(_: click.Context, port, worker, timeout): + """ + Start a grpc server for the agent service. + """ + import asyncio + + asyncio.run(_start_grpc_server(port, worker, timeout)) + + +async def _start_grpc_server(port: int, worker: int, timeout: int): + click.secho("Starting up the server to expose the prometheus metrics...", fg="blue") + from flytekit.extend.backend.agent_service import AgentMetadataService, AsyncAgentService + + try: + from prometheus_client import start_http_server + + start_http_server(9090) + except ImportError as e: + click.secho(f"Failed to start the prometheus server with error {e}", fg="red") + click.secho("Starting the agent service...", fg="blue") + server = aio.server(futures.ThreadPoolExecutor(max_workers=worker)) + + add_AsyncAgentServiceServicer_to_server(AsyncAgentService(), server) + add_AgentMetadataServiceServicer_to_server(AgentMetadataService(), server) + + server.add_insecure_port(f"[::]:{port}") + await server.start() + await server.wait_for_termination(timeout) diff --git a/flytekit/flytekit/clis/sdk_in_container/utils.py b/flytekit/flytekit/clis/sdk_in_container/utils.py new file mode 100644 index 0000000000..8c59b00c1c --- /dev/null +++ b/flytekit/flytekit/clis/sdk_in_container/utils.py @@ -0,0 +1,169 @@ +import os +import traceback +import typing +from dataclasses import Field, dataclass, field +from types import MappingProxyType + +import grpc +import rich_click as click +from google.protobuf.json_format import MessageToJson + +from flytekit.exceptions.base import FlyteException +from flytekit.exceptions.user import FlyteInvalidInputException +from flytekit.loggers import get_level_from_cli_verbosity, logger, upgrade_to_rich_logging + +project_option = click.Option( + param_decls=["-p", "--project"], + required=False, + type=str, + default=os.getenv("FLYTE_DEFAULT_PROJECT", "flytesnacks"), + show_default=True, + help="Project to register and run this workflow in. Can also be set through envvar " "``FLYTE_DEFAULT_PROJECT``", +) + +domain_option = click.Option( + param_decls=["-d", "--domain"], + required=False, + type=str, + default=os.getenv("FLYTE_DEFAULT_DOMAIN", "development"), + show_default=True, + help="Domain to register and run this workflow in, can also be set through envvar " "``FLYTE_DEFAULT_DOMAIN``", +) + +project_option_dec = click.option( + "-p", + "--project", + required=False, + type=str, + default=os.getenv("FLYTE_DEFAULT_PROJECT", "flytesnacks"), + show_default=True, + help="Project for workflow/launchplan. Can also be set through envvar " "``FLYTE_DEFAULT_PROJECT``", +) + +domain_option_dec = click.option( + "-d", + "--domain", + required=False, + type=str, + default=os.getenv("FLYTE_DEFAULT_DOMAIN", "development"), + show_default=True, + help="Domain for workflow/launchplan, can also be set through envvar " "``FLYTE_DEFAULT_DOMAIN``", +) + + +def validate_package(ctx, param, values): + """ + This method will validate the packages passed in by the user. It will check that the packages are in the correct + format, and will also split the packages if the user passed in a comma separated list. + """ + pkgs = [] + for val in values: + if "/" in val or "-" in val or "\\" in val: + raise click.BadParameter( + f"Illegal package value {val} for parameter: {param}. Expected for the form [a.b.c]" + ) + elif "," in val: + pkgs.extend(val.split(",")) + else: + pkgs.append(val) + logger.debug(f"Using packages: {pkgs}") + return pkgs + + +def pretty_print_grpc_error(e: grpc.RpcError): + """ + This method will print the grpc error that us more human readable. + """ + if isinstance(e, grpc._channel._InactiveRpcError): # noqa + 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 + + +def pretty_print_traceback(e): + """ + This method will print the Traceback of an error. + """ + if e.__traceback__: + stack_list = traceback.format_list(traceback.extract_tb(e.__traceback__)) + click.secho("Traceback:", fg="red") + for i in stack_list: + click.secho(f"{i}", fg="red") + + +def pretty_print_exception(e: Exception): + """ + This method will print the exception in a nice way. It will also check if the exception is a grpc.RpcError and + print it in a human-readable way. + """ + if isinstance(e, click.exceptions.Exit): + raise e + + if isinstance(e, click.ClickException): + click.secho(e.message, fg="red") + raise e + + if isinstance(e, FlyteException): + 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"\tInput Request: {MessageToJson(e.request)}", dim=True) + + cause = e.__cause__ + if cause: + if isinstance(cause, grpc.RpcError): + pretty_print_grpc_error(cause) + else: + pretty_print_traceback(cause) + return + + if isinstance(e, grpc.RpcError): + pretty_print_grpc_error(e) + return + + click.secho(f"Failed with Unknown Exception {type(e)} Reason: {e}", fg="red") # noqa + pretty_print_traceback(e) + + +class ErrorHandlingCommand(click.RichGroup): + """ + Helper class that wraps the invoke method of a click command to catch exceptions and print them in a nice way. + """ + + def invoke(self, ctx: click.Context) -> typing.Any: + verbose = ctx.params["verbose"] + log_level = get_level_from_cli_verbosity(verbose) + upgrade_to_rich_logging(log_level=log_level) + try: + return super().invoke(ctx) + except Exception as e: + if verbose > 0: + click.secho("Verbose mode on") + if isinstance(e, FlyteException): + raise e.with_traceback(None) + raise e + pretty_print_exception(e) + raise SystemExit(e) from e + + +def make_click_option_field(o: click.Option) -> Field: + if o.multiple: + o.help = click.style("Multiple values allowed.", bold=True) + f"{o.help}" + return field(default_factory=lambda: o.default, metadata={"click.option": o}) + return field(default=o.default, metadata={"click.option": o}) + + +def get_option_from_metadata(metadata: MappingProxyType) -> click.Option: + return metadata["click.option"] + + +@dataclass +class PyFlyteParams: + config_file: typing.Optional[str] = None + verbose: bool = False + pkgs: typing.List[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: typing.Dict[str, typing.Any]) -> "PyFlyteParams": + return cls(**d) diff --git a/flytekit/flytekit/clis/version.py b/flytekit/flytekit/clis/version.py new file mode 100644 index 0000000000..a9b4cad522 --- /dev/null +++ b/flytekit/flytekit/clis/version.py @@ -0,0 +1,27 @@ +import rich +import rich_click as click +from rich.panel import Panel + +from flytekit.clis.sdk_in_container.helpers import get_and_save_remote_with_click_context +from flytekit.remote import FlyteRemote + +Content = """ +This CLI is meant to be used within a virtual environment that has Flytekit installed. Ideally it is used to iterate on your Flyte workflows and tasks. + +Flytekit Version: [cyan]{version}[reset] +Flyte Backend Endpoint: [cyan]{endpoint} +""" + + +@click.command("info") +@click.pass_context +def info(ctx: click.Context): + """ + Print out information about the current Flyte Python CLI environment - like the version of Flytekit, backend endpoint + currently configured, etc. + """ + import flytekit + + remote: FlyteRemote = get_and_save_remote_with_click_context(ctx, project="flytesnacks", domain="development") + c = Content.format(version=flytekit.__version__, endpoint=remote.client.url) + rich.print(Panel(c, title="Flytekit CLI Info", border_style="purple", padding=(1, 1, 1, 1))) diff --git a/flytekit/flytekit/configuration/__init__.py b/flytekit/flytekit/configuration/__init__.py new file mode 100644 index 0000000000..9630802f45 --- /dev/null +++ b/flytekit/flytekit/configuration/__init__.py @@ -0,0 +1,933 @@ +""" +===================== +Configuration +===================== + +.. currentmodule:: flytekit.configuration + +Flytekit Configuration Sources +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +There are multiple ways to configure flytekit settings: + +**Command Line Arguments**: This is the recommended way of setting configuration values for many cases. +For example, see `pyflyte package `_ command. + +**Python Config Object**: A :py:class:`~flytekit.configuration.Config` object can by used directly, e.g. when +initializing a :py:class:`~flytefit.remote.remote.FlyteRemote` object. See :doc:`here ` for examples on +how to specify a ``Config`` object. + +**Environment Variables**: Users can specify these at compile time, but when your task is run, Flyte Propeller will +also set configuration to ensure correct interaction with the platform. The environment variables must be specified +with the format ``FLYTE_{SECTION}_{OPTION}``, all in upper case. For example, to specify the +:py:class:`PlatformConfig.endpoint ` setting, the environment variable would +be ``FLYTE_PLATFORM_URL``. + +.. note:: + + Environment variables won't work for image configuration, which need to be specified with the + `pyflyte package --image ... `_ option or in a configuration + file. + +**YAML Format Configuration File**: A configuration file that contains settings for both +`flytectl `__ and ``flytekit``. This is the recommended configuration +file format. Invoke the :ref:`flytectl config init ` command to create a boilerplate +``~/.flyte/config.yaml`` file, and ``flytectl --help`` to learn about all of the configuration yaml options. + +.. dropdown:: See example ``config.yaml`` file + :title: text-muted + :animate: fade-in-slide-down + + .. literalinclude:: ../../tests/flytekit/unit/configuration/configs/sample.yaml + :language: yaml + :caption: config.yaml + +**INI Format Configuration File**: A configuration file for ``flytekit``. By default, ``flytekit`` will look for a +file in two places: + +1. First, a file named ``flytekit.config`` in the Python interpreter's working directory. +2. A file in ``~/.flyte/config`` in the home directory as detected by Python. + +.. dropdown:: See example ``flytekit.config`` file + :title: text-muted + :animate: fade-in-slide-down + + .. literalinclude:: ../../tests/flytekit/unit/configuration/configs/images.config + :language: ini + :caption: flytekit.config + +.. warning:: + + The INI format configuration is considered a legacy configuration format. We recommend using the yaml format + instead if you're using a configuration file. + +How is configuration used? +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Configuration usage can roughly be bucketed into the following areas, + +- **Compile-time settings**: these are settings like the default image and named images, where to look for Flyte code, etc. +- **Platform settings**: Where to find the Flyte backend (Admin DNS, whether to use SSL) +- **Registration Run-time settings**: these are things like the K8s service account to use, a specific S3/GCS bucket to write off-loaded data (dataframes and files) to, notifications, labels & annotations, etc. +- **Data access settings**: Is there a custom S3 endpoint in use? Backoff/retry behavior for accessing S3/GCS, key and password, etc. +- **Other settings** - Statsd configuration, which is a run-time applicable setting but is not necessarily relevant to the Flyte platform. + +Configuration Objects +--------------------- + +The following objects are encapsulated in a parent object called ``Config``. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~Config + +.. _configuration-compile-time-settings: + +Serialization Time Settings +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +These are serialization/compile-time settings that are used when using commands like +`pyflyte package `_ or `pyflyte register `_. These +configuration settings are typically passed in as flags to the above CLI commands. + +The image configurations are typically either passed in via an `--image `_ flag, +or can be specified in the ``yaml`` or ``ini`` configuration files (see examples above). + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~Image + ~ImageConfig + ~SerializationSettings + ~FastSerializationSettings + +.. _configuration-execution-time-settings: + +Execution Time Settings +^^^^^^^^^^^^^^^^^^^^^^^ + +Users typically shouldn't be concerned with these configurations, as they are typically set by FlytePropeller or +FlyteAdmin. The configurations below are useful for authenticating to a Flyte backend, configuring data access +credentials, secrets, and statsd metrics. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~PlatformConfig + ~StatsConfig + ~SecretsConfig + ~S3Config + ~GCSConfig + ~DataConfig + +""" +from __future__ import annotations + +import base64 +import datetime +import enum +import gzip +import os +import pathlib +import re +import tempfile +import typing +from dataclasses import dataclass, field +from io import BytesIO +from typing import Dict, List, Optional + +import yaml +from dataclasses_json import DataClassJsonMixin + +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 }}" +DOMAIN_PLACEHOLDER = "{{ registration.domain }}" +VERSION_PLACEHOLDER = "{{ registration.version }}" +DEFAULT_RUNTIME_PYTHON_INTERPRETER = "/opt/venv/bin/python3" +DEFAULT_FLYTEKIT_ENTRYPOINT_FILELOC = "bin/entrypoint.py" +DEFAULT_IMAGE_NAME = "default" +DEFAULT_IN_CONTAINER_SRC_PATH = "/root" +_IMAGE_FQN_TAG_REGEX = re.compile(r"([^:]+)(?=:.+)?") +SERIALIZED_CONTEXT_ENV_VAR = "_F_SS_C" + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class Image(DataClassJsonMixin): + """ + Image is a structured wrapper for task container images used in object serialization. + + Attributes: + name (str): A user-provided name to identify this image. + fqn (str): Fully qualified image name. This consists of + #. a registry location + #. a username + #. a repository name + For example: `hostname/username/reponame` + tag (str): Optional tag used to specify which version of an image to pull + """ + + name: str + fqn: str + tag: str + + @property + def full(self) -> str: + """ " + Return the full image name with tag. + """ + return f"{self.fqn}:{self.tag}" + + @staticmethod + def look_up_image_info(name: str, tag: str, optional_tag: bool = False) -> Image: + """ + Looks up the image tag from environment variable (should be set from the Dockerfile). + FLYTE_INTERNAL_IMAGE should be the environment variable. + + This function is used when registering tasks/workflows with Admin. When using + the canonical Python-based development cycle, the version that is used to + register workflows and tasks with Admin should be the version of the image + itself, which should ideally be something unique like the git revision SHA1 of + the latest commit. + + :param optional_tag: + :param name: + :param Text tag: e.g. somedocker.com/myimage:someversion123 + :rtype: Text + """ + from docker.utils import parse_repository_tag + + if pathlib.Path(tag).is_file(): + 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() + + fqn, parsed_tag = parse_repository_tag(tag) + if not optional_tag and parsed_tag is None: + raise AssertionError(f"Incorrectly formatted image {tag}, missing tag value") + else: + return Image(name=name, fqn=fqn, tag=parsed_tag) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class ImageConfig(DataClassJsonMixin): + """ + We recommend you to use ImageConfig.auto(img_name=None) to create an ImageConfig. + For example, ImageConfig.auto(img_name=""ghcr.io/flyteorg/flytecookbook:v1.0.0"") will create an ImageConfig. + + ImageConfig holds available images which can be used at registration time. A default image can be specified + along with optional additional images. Each image in the config must have a unique name. + + Attributes: + default_image (Optional[Image]): The default image to be used as a container for task serialization. + images (List[Image]): Optional, additional images which can be used in task container definitions. + """ + + default_image: Optional[Image] = None + images: Optional[List[Image]] = None + + def find_image(self, name) -> Optional[Image]: + """ + Return an image, by name, if it exists. + """ + lookup_images = [self.default_image] if self.default_image else [] + if self.images: + lookup_images.extend(self.images) + + for i in lookup_images: + if i.name == name: + return i + return None + + @staticmethod + def validate_image(_: typing.Any, param: str, values: tuple) -> ImageConfig: + """ + Validates the image to match the standard format. Also validates that only one default image + is provided. a default image, is one that is specified as ``default=`` or just ````. All + other images should be provided with a name, in the format ``name=`` This method can be used with the + CLI + + :param _: click argument, ignored here. + :param param: the click argument, here should be "image" + :param values: user-supplied images + :return: + """ + default_image = None + images = [] + for v in values: + if "=" in v: + splits = v.split("=", maxsplit=1) + img = Image.look_up_image_info(name=splits[0], tag=splits[1], optional_tag=False) + else: + img = Image.look_up_image_info(DEFAULT_IMAGE_NAME, v, False) + + if default_image and img.name == DEFAULT_IMAGE_NAME: + raise ValueError( + f"Only one default image can be specified. Received multiple {default_image} & {img} for {param}" + ) + if img.name == DEFAULT_IMAGE_NAME: + default_image = img + else: + images.append(img) + + if default_image is None: + default_image_str = os.environ.get("FLYTE_INTERNAL_IMAGE", DefaultImages.default_image()) + default_image = Image.look_up_image_info(DEFAULT_IMAGE_NAME, default_image_str, False) + return ImageConfig.create_from(default_image=default_image, other_images=images) + + @classmethod + def create_from( + cls, default_image: Optional[Image], other_images: typing.Optional[typing.List[Image]] = None + ) -> ImageConfig: + if default_image and not isinstance(default_image, Image): + raise ValueError(f"Default image should be of type Image or None not {type(default_image)}") + all_images = [default_image] if default_image else [] + if other_images: + all_images.extend(other_images) + return ImageConfig(default_image=default_image, images=all_images) + + @classmethod + def auto( + cls, config_file: typing.Union[str, ConfigFile, None] = None, img_name: Optional[str] = None + ) -> ImageConfig: + """ + Reads from config file or from img_name + Note that this function does not take into account the flytekit default images (see the Dockerfiles at the + base of this repo). To pick those up, see the auto_default_image function.. + + :param config_file: + :param img_name: + :return: + """ + default_img = Image.look_up_image_info("default", img_name) if img_name else None + + config_file = get_config_file(config_file) + other_images = [ + Image.look_up_image_info(k, tag=v, optional_tag=True) + for k, v in _internal.Images.get_specified_images(config_file).items() + ] + return cls.create_from(default_img, other_images) + + @classmethod + def from_images(cls, default_image: str, m: typing.Optional[typing.Dict[str, str]] = None): + """ + Allows you to programmatically create an ImageConfig. Usually only the default_image is required, unless + your workflow uses multiple images + + .. code:: python + + ImageConfig.from_dict( + "ghcr.io/flyteorg/flytecookbook:v1.0.0", + { + "spark": "ghcr.io/flyteorg/myspark:...", + "other": "...", + } + ) + + :return: + """ + m = m or {} + def_img = Image.look_up_image_info("default", default_image) if default_image else None + other_images = [Image.look_up_image_info(k, tag=v, optional_tag=True) for k, v in m.items()] + return cls.create_from(def_img, other_images) + + @classmethod + def auto_default_image(cls) -> ImageConfig: + return cls.auto(img_name=DefaultImages.default_image()) + + +class AuthType(enum.Enum): + STANDARD = "standard" + BASIC = "basic" + CLIENT_CREDENTIALS = "client_credentials" + EXTERNAL_PROCESS = "external_process" + # The following values are copied from flyteidl's admin client to align the two code bases on the same enum values. + # The enum values above will continue to work. + CLIENTSECRET = "ClientSecret" + PKCE = "Pkce" + EXTERNALCOMMAND = "ExternalCommand" + DEVICEFLOW = "DeviceFlow" + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class PlatformConfig(object): + """ + This object contains the settings to talk to a Flyte backend (the DNS location of your Admin server basically). + + :param endpoint: DNS for Flyte backend + :param insecure: Whether or not to use SSL + :param insecure_skip_verify: Whether to skip SSL certificate verification + :param console_endpoint: endpoint for console if different from Flyte backend + :param command: This command is executed to return a token using an external process + :param proxy_command: This command is executed to return a token for proxy authorization using an external process + :param client_id: This is the public identifier for the app which handles authorization for a Flyte deployment. + More details here: https://www.oauth.com/oauth2-servers/client-registration/client-id-secret/. + :param client_credentials_secret: Used for service auth, which is automatically called during pyflyte. This will + allow the Flyte engine to read the password directly from the environment variable. Note that this is + less secure! Please only use this if mounting the secret as a file is impossible + :param scopes: List of scopes to request. This is only applicable to the client credentials flow + :param auth_mode: The OAuth mode to use. Defaults to pkce flow + :param ca_cert_file_path: [optional] str Root Cert to be loaded and used to verify admin + :param http_proxy_url: [optional] HTTP Proxy to be used for OAuth requests + """ + + endpoint: str = "localhost:30080" + insecure: bool = False + insecure_skip_verify: bool = False + ca_cert_file_path: typing.Optional[str] = None + console_endpoint: typing.Optional[str] = None + command: typing.Optional[typing.List[str]] = None + proxy_command: typing.Optional[typing.List[str]] = None + client_id: typing.Optional[str] = None + client_credentials_secret: typing.Optional[str] = None + scopes: List[str] = field(default_factory=list) + auth_mode: AuthType = AuthType.STANDARD + audience: typing.Optional[str] = None + rpc_retries: int = 3 + http_proxy_url: typing.Optional[str] = None + + @classmethod + def auto(cls, config_file: typing.Optional[typing.Union[str, ConfigFile]] = None) -> PlatformConfig: + """ + Reads from Config file, and overrides from Environment variables. Refer to ConfigEntry for details + :param config_file: + :return: + """ + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "insecure", _internal.Platform.INSECURE.read(config_file)) + kwargs = set_if_exists( + kwargs, "insecure_skip_verify", _internal.Platform.INSECURE_SKIP_VERIFY.read(config_file) + ) + kwargs = set_if_exists(kwargs, "ca_cert_file_path", _internal.Platform.CA_CERT_FILE_PATH.read(config_file)) + kwargs = set_if_exists(kwargs, "command", _internal.Credentials.COMMAND.read(config_file)) + kwargs = set_if_exists(kwargs, "proxy_command", _internal.Credentials.PROXY_COMMAND.read(config_file)) + kwargs = set_if_exists(kwargs, "client_id", _internal.Credentials.CLIENT_ID.read(config_file)) + kwargs = set_if_exists( + kwargs, "client_credentials_secret", _internal.Credentials.CLIENT_CREDENTIALS_SECRET.read(config_file) + ) + + is_client_secret = False + client_credentials_secret = read_file_if_exists( + _internal.Credentials.CLIENT_CREDENTIALS_SECRET_LOCATION.read(config_file) + ) + if client_credentials_secret: + is_client_secret = True + if client_credentials_secret.endswith("\n"): + logger.info("Newline stripped from client secret") + client_credentials_secret = client_credentials_secret.strip() + kwargs = set_if_exists( + kwargs, + "client_credentials_secret", + client_credentials_secret, + ) + + client_credentials_secret_env_var = _internal.Credentials.CLIENT_CREDENTIALS_SECRET_ENV_VAR.read(config_file) + if client_credentials_secret_env_var: + client_credentials_secret = os.getenv(client_credentials_secret_env_var) + if client_credentials_secret: + is_client_secret = True + kwargs = set_if_exists(kwargs, "client_credentials_secret", client_credentials_secret) + kwargs = set_if_exists(kwargs, "scopes", _internal.Credentials.SCOPES.read(config_file)) + kwargs = set_if_exists(kwargs, "auth_mode", _internal.Credentials.AUTH_MODE.read(config_file)) + if is_client_secret: + kwargs = set_if_exists(kwargs, "auth_mode", AuthType.CLIENTSECRET.value) + kwargs = set_if_exists(kwargs, "endpoint", _internal.Platform.URL.read(config_file)) + kwargs = set_if_exists(kwargs, "console_endpoint", _internal.Platform.CONSOLE_ENDPOINT.read(config_file)) + + kwargs = set_if_exists(kwargs, "http_proxy_url", _internal.Platform.HTTP_PROXY_URL.read(config_file)) + return PlatformConfig(**kwargs) + + @classmethod + def for_endpoint(cls, endpoint: str, insecure: bool = False) -> PlatformConfig: + return PlatformConfig(endpoint=endpoint, insecure=insecure) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class StatsConfig(object): + """ + Configuration for sending statsd. + + :param host: The statsd host + :param port: statsd port + :param disabled: Whether or not to send + :param disabled_tags: Turn on to reduce cardinality. + """ + + host: str = "localhost" + port: int = 8125 + disabled: bool = False + disabled_tags: bool = False + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> StatsConfig: + """ + Reads from environment variable, followed by ConfigFile provided + :param config_file: + :return: + """ + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "host", _internal.StatsD.HOST.read(config_file)) + kwargs = set_if_exists(kwargs, "port", _internal.StatsD.PORT.read(config_file)) + kwargs = set_if_exists(kwargs, "disabled", _internal.StatsD.DISABLED.read(config_file)) + kwargs = set_if_exists(kwargs, "disabled_tags", _internal.StatsD.DISABLE_TAGS.read(config_file)) + return StatsConfig(**kwargs) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class SecretsConfig(object): + """ + Configuration for secrets. + + :param env_prefix: This is the prefix that will be used to lookup for injected secrets at runtime. + :param default_dir: This is the default directory that will be used to find secrets as individual files under. + :param file_prefix: This is the prefix for the file in the default dir. + """ + + env_prefix: str = "_FSEC_" + default_dir: str = os.path.join(os.sep, "etc", "secrets") + file_prefix: str = "" + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> SecretsConfig: + """ + Reads from environment variable or from config file + :param config_file: + :return: + """ + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "env_prefix", _internal.Secrets.ENV_PREFIX.read(config_file)) + kwargs = set_if_exists(kwargs, "default_dir", _internal.Secrets.DEFAULT_DIR.read(config_file)) + kwargs = set_if_exists(kwargs, "file_prefix", _internal.Secrets.FILE_PREFIX.read(config_file)) + return SecretsConfig(**kwargs) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class S3Config(object): + """ + S3 specific configuration + """ + + enable_debug: bool = False + endpoint: typing.Optional[str] = None + retries: int = 3 + backoff: datetime.timedelta = datetime.timedelta(seconds=5) + access_key_id: typing.Optional[str] = None + secret_access_key: typing.Optional[str] = None + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> S3Config: + """ + Automatically configure + :param config_file: + :return: Config + """ + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "enable_debug", _internal.AWS.ENABLE_DEBUG.read(config_file)) + kwargs = set_if_exists(kwargs, "endpoint", _internal.AWS.S3_ENDPOINT.read(config_file)) + kwargs = set_if_exists(kwargs, "retries", _internal.AWS.RETRIES.read(config_file)) + kwargs = set_if_exists(kwargs, "backoff", _internal.AWS.BACKOFF_SECONDS.read(config_file)) + kwargs = set_if_exists(kwargs, "access_key_id", _internal.AWS.S3_ACCESS_KEY_ID.read(config_file)) + kwargs = set_if_exists(kwargs, "secret_access_key", _internal.AWS.S3_SECRET_ACCESS_KEY.read(config_file)) + return S3Config(**kwargs) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class GCSConfig(object): + """ + Any GCS specific configuration. + """ + + gsutil_parallelism: bool = False + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> GCSConfig: + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "gsutil_parallelism", _internal.GCP.GSUTIL_PARALLELISM.read(config_file)) + return GCSConfig(**kwargs) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class AzureBlobStorageConfig(object): + """ + Any Azure Blob Storage specific configuration. + """ + + account_name: typing.Optional[str] = None + account_key: typing.Optional[str] = None + tenant_id: typing.Optional[str] = None + client_id: typing.Optional[str] = None + client_secret: typing.Optional[str] = None + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> GCSConfig: + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "account_name", _internal.AZURE.STORAGE_ACCOUNT_NAME.read(config_file)) + kwargs = set_if_exists(kwargs, "account_key", _internal.AZURE.STORAGE_ACCOUNT_KEY.read(config_file)) + kwargs = set_if_exists(kwargs, "tenant_id", _internal.AZURE.TENANT_ID.read(config_file)) + kwargs = set_if_exists(kwargs, "client_id", _internal.AZURE.CLIENT_ID.read(config_file)) + kwargs = set_if_exists(kwargs, "client_secret", _internal.AZURE.CLIENT_SECRET.read(config_file)) + return AzureBlobStorageConfig(**kwargs) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class DataConfig(object): + """ + Any data storage specific configuration. Please do not use this to store secrets, in S3 case, as it is used in + Flyte sandbox environment we store the access key id and secret. + All DataPersistence plugins are passed all DataConfig and the plugin should correctly use the right config + """ + + s3: S3Config = S3Config() + gcs: GCSConfig = GCSConfig() + azure: AzureBlobStorageConfig = AzureBlobStorageConfig() + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> DataConfig: + config_file = get_config_file(config_file) + return DataConfig( + azure=AzureBlobStorageConfig.auto(config_file), + s3=S3Config.auto(config_file), + gcs=GCSConfig.auto(config_file), + ) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class LocalConfig(object): + """ + Any configuration specific to local runs. + """ + + cache_enabled: bool = True + cache_overwrite: bool = False + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile] = None) -> LocalConfig: + config_file = get_config_file(config_file) + kwargs = {} + kwargs = set_if_exists(kwargs, "cache_enabled", _internal.Local.CACHE_ENABLED.read(config_file)) + kwargs = set_if_exists(kwargs, "cache_overwrite", _internal.Local.CACHE_OVERWRITE.read(config_file)) + return LocalConfig(**kwargs) + + +@dataclass(init=True, repr=True, eq=True, frozen=True) +class Config(object): + """ + This the parent configuration object and holds all the underlying configuration object types. An instance of + this object holds all the config necessary to + + 1. Interactive session with Flyte backend + 2. Some parts are required for Serialization, for example Platform Config is not required + 3. Runtime of a task + + Args: + entrypoint_settings: EntrypointSettings object for use with Spark tasks. If supplied, this will be + used when serializing Spark tasks, which need to know the path to the flytekit entrypoint.py file, + inside the container. + """ + + platform: PlatformConfig = PlatformConfig() + secrets: SecretsConfig = SecretsConfig() + stats: StatsConfig = StatsConfig() + data_config: DataConfig = DataConfig() + local_sandbox_path: str = tempfile.mkdtemp(prefix="flyte") + + def with_params( + self, + platform: PlatformConfig = None, + secrets: SecretsConfig = None, + stats: StatsConfig = None, + data_config: DataConfig = None, + local_sandbox_path: str = None, + ) -> Config: + return Config( + platform=platform or self.platform, + secrets=secrets or self.secrets, + stats=stats or self.stats, + data_config=data_config or self.data_config, + local_sandbox_path=local_sandbox_path or self.local_sandbox_path, + ) + + @classmethod + def auto(cls, config_file: typing.Union[str, ConfigFile, None] = None) -> Config: + """ + Automatically constructs the Config Object. The order of precedence is as follows + 1. first try to find any env vars that match the config vars specified in the FLYTE_CONFIG format. + 2. If not found in environment then values ar read from the config file + 3. If not found in the file, then the default values are used. + + :param config_file: file path to read the config from, if not specified default locations are searched + :return: Config + """ + config_file = get_config_file(config_file) + kwargs = {} + set_if_exists(kwargs, "local_sandbox_path", _internal.LocalSDK.LOCAL_SANDBOX.read(cfg=config_file)) + return Config( + platform=PlatformConfig.auto(config_file), + secrets=SecretsConfig.auto(config_file), + stats=StatsConfig.auto(config_file), + data_config=DataConfig.auto(config_file), + **kwargs, + ) + + @classmethod + def for_sandbox(cls) -> Config: + """ + Constructs a new Config object specifically to connect to :std:ref:`deployment-deployment-sandbox`. + If you are using a hosted Sandbox like environment, then you may need to use port-forward or ingress urls + :return: Config + """ + return Config( + platform=PlatformConfig(endpoint="localhost:30080", auth_mode="Pkce", insecure=True), + data_config=DataConfig( + s3=S3Config(endpoint="http://localhost:30002", access_key_id="minio", secret_access_key="miniostorage") + ), + ) + + @classmethod + def for_endpoint( + cls, + endpoint: str, + insecure: bool = False, + data_config: typing.Optional[DataConfig] = None, + config_file: typing.Union[str, ConfigFile] = None, + ) -> Config: + """ + Creates an automatic config for the given endpoint and uses the config_file or environment variable for default. + Refer to `Config.auto()` to understand the default bootstrap behavior. + + data_config can be used to configure how data is downloaded or uploaded to a specific Blob storage like S3 / GCS etc. + But, for permissions to a specific backend just use Cloud providers reqcommendation. If using fsspec, then + refer to fsspec documentation + :param endpoint: -> Endpoint where Flyte admin is available + :param insecure: -> if the connection should be insecure, default is secure (SSL ON) + :param data_config: -> Data config, if using specialized connection params like minio etc + :param config_file: -> Optional config file in the flytekit config format. + :return: Config + """ + c = cls.auto(config_file) + return c.with_params(platform=PlatformConfig.for_endpoint(endpoint, insecure), data_config=data_config) + + +@dataclass +class EntrypointSettings(DataClassJsonMixin): + """ + This object carries information about the path of the entrypoint command that will be invoked at runtime. + This is where `pyflyte-execute` code can be found. This is useful for cases like pyspark execution. + """ + + path: Optional[str] = None + + +@dataclass +class FastSerializationSettings(DataClassJsonMixin): + """ + This object hold information about settings necessary to serialize an object so that it can be fast-registered. + """ + + enabled: bool = False + # This is the location that the code should be copied into. + destination_dir: Optional[str] = None + + # This is the zip file where the new code was uploaded to. + distribution_location: Optional[str] = None + + +# TODO: ImageConfig, python_interpreter, venv_root, fast_serialization_settings.destination_dir should be combined. +@dataclass +class SerializationSettings(DataClassJsonMixin): + """ + These settings are provided while serializing a workflow and task, before registration. This is required to get + runtime information at serialization time, as well as some defaults. + + Attributes: + project (str): The project (if any) with which to register entities under. + domain (str): The domain (if any) with which to register entities under. + version (str): The version (if any) with which to register entities under. + image_config (ImageConfig): The image config used to define task container images. + env (Optional[Dict[str, str]]): Environment variables injected into task container definitions. + flytekit_virtualenv_root (Optional[str]): During out of container serialize the absolute path of the flytekit + virtualenv at serialization time won't match the in-container value at execution time. This optional value + is used to provide the in-container virtualenv path + python_interpreter (Optional[str]): The python executable to use. This is used for spark tasks in out of + container execution. + entrypoint_settings (Optional[EntrypointSettings]): Information about the command, path and version of the + entrypoint program. + 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 + project: typing.Optional[str] = None + domain: typing.Optional[str] = None + version: typing.Optional[str] = None + env: Optional[Dict[str, str]] = None + git_repo: Optional[str] = None + 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: + self.flytekit_virtualenv_root = self.venv_root_from_interpreter(self.python_interpreter) + + @property + def entrypoint_settings(self) -> EntrypointSettings: + return EntrypointSettings( + path=os.path.join( + SerializationSettings.venv_root_from_interpreter(self.python_interpreter), + DEFAULT_FLYTEKIT_ENTRYPOINT_FILELOC, + ) + ) + + @classmethod + def from_transport(cls, s: str) -> SerializationSettings: + compressed_val = base64.b64decode(s.encode("utf-8")) + json_str = gzip.decompress(compressed_val).decode("utf-8") + return cls.from_json(json_str) + + @classmethod + def for_image( + cls, + image: str, + version: str, + project: str = "", + domain: str = "", + python_interpreter_path: str = DEFAULT_RUNTIME_PYTHON_INTERPRETER, + ) -> SerializationSettings: + img = ImageConfig(default_image=Image.look_up_image_info(DEFAULT_IMAGE_NAME, tag=image)) + return SerializationSettings( + image_config=img, + project=project, + domain=domain, + version=version, + python_interpreter=python_interpreter_path, + flytekit_virtualenv_root=cls.venv_root_from_interpreter(python_interpreter_path), + ) + + @staticmethod + def venv_root_from_interpreter(interpreter_path: str) -> str: + """ + Computes the path of the virtual environment root, based on the passed in python interpreter path + for example /opt/venv/bin/python3 -> /opt/venv + """ + return os.path.dirname(os.path.dirname(interpreter_path)) + + @staticmethod + def default_entrypoint_settings(interpreter_path: str) -> EntrypointSettings: + """ + Assumes the entrypoint is installed in a virtual-environment where the interpreter is + """ + return EntrypointSettings( + path=os.path.join( + SerializationSettings.venv_root_from_interpreter(interpreter_path), DEFAULT_FLYTEKIT_ENTRYPOINT_FILELOC + ) + ) + + def new_builder(self) -> Builder: + """ + Creates a ``SerializationSettings.Builder`` that copies the existing serialization settings parameters and + allows for customization. + """ + return SerializationSettings.Builder( + project=self.project, + domain=self.domain, + version=self.version, + image_config=self.image_config, + env=self.env.copy() if self.env else None, + git_repo=self.git_repo, + 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: + """ + Whether or not the serialization settings specify that entities should be serialized for fast registration. + """ + return self.fast_serialization_settings is not None and self.fast_serialization_settings.enabled + + def _has_serialized_context(self) -> bool: + return self.env and SERIALIZED_CONTEXT_ENV_VAR in self.env + + @property + def serialized_context(self) -> str: + """ + :return: returns the serialization context as a base64encoded, gzip compressed, json strinnn + """ + if self._has_serialized_context(): + return self.env[SERIALIZED_CONTEXT_ENV_VAR] + json_str = self.to_json() + buf = BytesIO() + with gzip.GzipFile(mode="wb", fileobj=buf, mtime=0) as f: + f.write(json_str.encode("utf-8")) + return base64.b64encode(buf.getvalue()).decode("utf-8") + + def with_serialized_context(self) -> SerializationSettings: + """ + Use this method to create a new SerializationSettings that has an environment variable set with the SerializedContext + This is useful in transporting SerializedContext to serialized and registered tasks. + The setting will be available in the `env` field with the key `SERIALIZED_CONTEXT_ENV_VAR` + :return: A newly constructed SerializationSettings, or self, if it already has the serializationSettings + """ + if self._has_serialized_context(): + return self + b = self.new_builder() + if not b.env: + b.env = {} + b.env[SERIALIZED_CONTEXT_ENV_VAR] = self.serialized_context + return b.build() + + @dataclass + class Builder(object): + project: str + domain: str + version: str + image_config: ImageConfig + env: Optional[Dict[str, str]] = None + git_repo: Optional[str] = None + 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 + return self + + def build(self) -> SerializationSettings: + return SerializationSettings( + project=self.project, + domain=self.domain, + version=self.version, + image_config=self.image_config, + env=self.env, + git_repo=self.git_repo, + 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/flytekit/configuration/default_images.py b/flytekit/flytekit/configuration/default_images.py new file mode 100644 index 0000000000..380f428154 --- /dev/null +++ b/flytekit/flytekit/configuration/default_images.py @@ -0,0 +1,58 @@ +import enum +import sys +import typing +from contextlib import suppress + + +class PythonVersion(enum.Enum): + PYTHON_3_8 = (3, 8) + PYTHON_3_9 = (3, 9) + PYTHON_3_10 = (3, 10) + PYTHON_3_11 = (3, 11) + PYTHON_3_12 = (3, 12) + + +class DefaultImages(object): + """ + We may want to load the default images from remote - maybe s3 location etc? + """ + + _DEFAULT_IMAGE_PREFIXES = { + PythonVersion.PYTHON_3_8: "cr.flyte.org/flyteorg/flytekit:py3.8-", + PythonVersion.PYTHON_3_9: "cr.flyte.org/flyteorg/flytekit:py3.9-", + PythonVersion.PYTHON_3_10: "cr.flyte.org/flyteorg/flytekit:py3.10-", + PythonVersion.PYTHON_3_11: "cr.flyte.org/flyteorg/flytekit:py3.11-", + PythonVersion.PYTHON_3_12: "cr.flyte.org/flyteorg/flytekit:py3.12-", + } + + @classmethod + def default_image(cls) -> str: + from flytekit.configuration.plugin import get_plugin + + with suppress(AttributeError): + default_image = get_plugin().get_default_image() + if default_image is not None: + return default_image + + return cls.find_image_for() + + @classmethod + 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 "dev" in __version__: + version_suffix = "latest" + else: + version_suffix = __version__ + return version_suffix diff --git a/flytekit/flytekit/configuration/feature_flags.py b/flytekit/flytekit/configuration/feature_flags.py new file mode 100644 index 0000000000..9fa3d20cfd --- /dev/null +++ b/flytekit/flytekit/configuration/feature_flags.py @@ -0,0 +1,16 @@ +import os + + +def _get(key: str, default_val: str) -> str: + v = os.environ.get(key) + return v if v else default_val + + +class FeatureFlags: + FLYTE_PYTHON_PACKAGE_ROOT = _get("FLYTE_PYTHON_PACKAGE_ROOT", "auto") + """ + Valid values, + "auto" -> Automatically locate the fully qualified module name. This assumes that every python package directory is valid and has an _init__.py file + "." -> Assuming current directory as the root + or an actual path -> path to the root, this will be used to locate the root. + """ diff --git a/flytekit/flytekit/configuration/file.py b/flytekit/flytekit/configuration/file.py new file mode 100644 index 0000000000..32502568ba --- /dev/null +++ b/flytekit/flytekit/configuration/file.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import configparser +import configparser as _configparser +import os +import pathlib +import typing +from dataclasses import dataclass +from os import getenv +from pathlib import Path + +import yaml + +from flytekit.exceptions import user as _user_exceptions +from flytekit.loggers import logger + +# This is the env var that the flytectl sandbox instructions say to set +FLYTECTL_CONFIG_ENV_VAR = "FLYTECTL_CONFIG" +# This is an explicit override only to be used by pyflyte and takes precedence in get_config_file over the main +# environment variable. +# This env var should not be set by users +FLYTECTL_CONFIG_ENV_VAR_OVERRIDE = "_FLYTECTL_CONFIG_PYFLYTE_OVERRIDE" + + +def _exists(val: typing.Any) -> bool: + """Check if a value is defined.""" + return isinstance(val, bool) or bool(val is not None and val) + + +@dataclass +class LegacyConfigEntry(object): + """ + Creates a record for the config entry. contains + Args: + section: section the option should be found under + option: the option str to lookup + type_: Expected type of the value + """ + + section: str + option: str + type_: typing.Type = str + + def get_env_name(self) -> str: + return f"FLYTE_{self.section.upper()}_{self.option.upper()}" + + def read_from_env(self, transform: typing.Optional[typing.Callable] = None) -> typing.Optional[typing.Any]: + """ + Reads the config entry from environment variable, the structure of the env var is current + ``FLYTE_{SECTION}_{OPTION}`` all upper cased. We will change this in the future. + :return: + """ + env = self.get_env_name() + v = os.environ.get(env, None) + if v is None: + return None + return transform(v) if transform else v + + def read_from_file( + self, cfg: ConfigFile, transform: typing.Optional[typing.Callable] = None + ) -> typing.Optional[typing.Any]: + if not cfg: + return None + try: + v = cfg.get(self) + return transform(v) if transform else v + except configparser.Error: + pass + return None + + +@dataclass +class YamlConfigEntry(object): + """ + Creates a record for the config entry. + Args: + switch: dot-delimited string that should match flytectl args. Leaving it as dot-delimited instead of a list + of strings because it's easier to maintain alignment with flytectl. + config_value_type: Expected type of the value + """ + + switch: str + config_value_type: typing.Type = str + + def read_from_file( + self, cfg: ConfigFile, transform: typing.Optional[typing.Callable] = None + ) -> typing.Optional[typing.Any]: + if not cfg: + return None + try: + v = cfg.get(self) + if _exists(v): + return transform(v) if transform else v + except Exception: + ... + + return None + + +def bool_transformer(config_val: typing.Any) -> bool: + if type(config_val) is str: + return True if config_val and config_val.lower() not in ["false", "0", "off", "no"] else False + else: + return config_val + + +def comma_list_transformer(config_val: typing.Any): + if type(config_val) is str: + return config_val.split(",") + else: + return config_val + + +def int_transformer(config_val: typing.Any): + if type(config_val) is str: + try: + return int(config_val) + except ValueError: + logger.warning(f"Couldn't convert configuration setting {config_val} into {int}, leaving as is.") + return config_val + + +@dataclass +class ConfigEntry(object): + """ + A top level Config entry holder, that holds multiple different representations of the config. + Legacy means the INI style config files. YAML support is for the flytectl config file, which is there by default + when flytectl starts a sandbox + """ + + legacy: LegacyConfigEntry + yaml_entry: typing.Optional[YamlConfigEntry] = None + transform: typing.Optional[typing.Callable[[str], typing.Any]] = None + + legacy_default_transforms = { + bool: bool_transformer, + list: comma_list_transformer, + int: int_transformer, + } + + def __post_init__(self): + if self.legacy: + if not self.transform and self.legacy.type_ in ConfigEntry.legacy_default_transforms: + self.transform = ConfigEntry.legacy_default_transforms[self.legacy.type_] + + def read(self, cfg: typing.Optional[ConfigFile] = None) -> typing.Optional[typing.Any]: + """ + Reads the config Entry from the various sources in the following order, + #. First try to read from the relevant environment variable, + #. If missing, then try to read from the legacy config file, if one was parsed. + #. If missing, then try to read from the yaml file. + + The constructor for ConfigFile currently does not allow specification of both the ini and yaml style formats. + + :param cfg: + :return: + """ + from_env = self.legacy.read_from_env(self.transform) + if from_env is not None: + return from_env + if cfg and cfg.legacy_config: + return self.legacy.read_from_file(cfg, self.transform) + if cfg and cfg.yaml_config and self.yaml_entry: + return self.yaml_entry.read_from_file(cfg, self.transform) + + return None + + +class ConfigFile(object): + def __init__(self, location: str): + """ + Load the config from this location + """ + self._location = location + if location.endswith("yaml") or location.endswith("yml"): + self._legacy_config = None + self._yaml_config = self._read_yaml_config(location) + else: + self._legacy_config = self._read_legacy_config(location) + self._yaml_config = None + + @staticmethod + def _read_yaml_config(location: str) -> typing.Optional[typing.Dict[str, typing.Any]]: + with open(location, "r") as fh: + try: + yaml_contents = yaml.safe_load(fh) + return yaml_contents + except yaml.YAMLError as exc: + logger.warning(f"Error {exc} reading yaml config file at {location}, ignoring...") + return None + + def _read_legacy_config(self, location: str) -> _configparser.ConfigParser: + c = _configparser.ConfigParser() + c.read(self._location) + if c.has_section("internal"): + raise _user_exceptions.FlyteAssertion( + "The config file '{}' cannot contain a section for internal " "only configurations.".format(location) + ) + return c + + def _get_from_legacy(self, c: LegacyConfigEntry) -> typing.Any: + if issubclass(c.type_, bool): + return self._legacy_config.getboolean(c.section, c.option) + + if issubclass(c.type_, int): + return self._legacy_config.getint(c.section, c.option) + + if issubclass(c.type_, list): + v = self._legacy_config.get(c.section, c.option) + return v.split(",") + + return self._legacy_config.get(c.section, c.option) + + def _get_from_yaml(self, c: YamlConfigEntry) -> typing.Any: + keys = c.switch.split(".") # flytectl switches are dot delimited + d = self.yaml_config + try: + for k in keys: + d = d[k] + return d + except KeyError: + logger.debug(f"Switch {c.switch} could not be found in yaml config") + return None + + def get(self, c: typing.Union[LegacyConfigEntry, YamlConfigEntry]) -> typing.Any: + if isinstance(c, LegacyConfigEntry): + return self._get_from_legacy(c) + if isinstance(c, YamlConfigEntry) and self.yaml_config: + return self._get_from_yaml(c) + raise NotImplementedError("Support for other config types besides .ini / .config files not yet supported") + + @property + def legacy_config(self) -> _configparser.ConfigParser: + return self._legacy_config + + @property + def yaml_config(self) -> typing.Dict[str, typing.Any]: + return self._yaml_config + + +def get_config_file(c: typing.Union[str, ConfigFile, None]) -> typing.Optional[ConfigFile]: + """ + Checks if the given argument is a file or a configFile and returns a loaded configFile else returns None + """ + if c is None: + # Pyflyte override env var takes highest precedence + # Env var takes second highest precedence + flytectl_path_from_env = getenv(FLYTECTL_CONFIG_ENV_VAR_OVERRIDE, getenv(FLYTECTL_CONFIG_ENV_VAR, None)) + if flytectl_path_from_env: + flytectl_path = Path(flytectl_path_from_env) + if flytectl_path.exists(): + logger.info(f"Using flytectl/YAML config {flytectl_path.absolute()}") + return ConfigFile(str(flytectl_path.absolute())) + else: + logger.warning(f"flytectl config file {flytectl_path.absolute()} does not exist, ignoring...") + + # See if there's a config file in the current directory where Python is being run from + current_location_config = Path("flytekit.config") + if current_location_config.exists(): + logger.info(f"Using configuration from Python process root {current_location_config.absolute()}") + return ConfigFile(str(current_location_config.absolute())) + + # If not, see if there's a config in the user's home directory + home_dir_config = Path(Path.home(), ".flyte", "config") # _default_config_file_name in main.py + if home_dir_config.exists(): + logger.info(f"Using configuration from home directory {home_dir_config.absolute()}") + return ConfigFile(str(home_dir_config.absolute())) + + # If not see if there's something in the default home directory location + flytectl_path = Path(Path.home(), ".flyte", "config.yaml") + if flytectl_path.exists(): + logger.info(f"Using flytectl/YAML config {flytectl_path.absolute()}") + return ConfigFile(str(flytectl_path.absolute())) + + # If not, then return None and let caller handle + return None + if isinstance(c, str): + logger.debug(f"Using specified config file at {c}") + return ConfigFile(c) + return c + + +def set_if_exists(d: dict, k: str, v: typing.Any) -> dict: + """ + Given a dict ``d`` sets the key ``k`` with value of config ``v``, if the config value ``v`` is set + and return the updated dictionary. + + .. note:: + + The input dictionary ``d`` will be mutated. + """ + if _exists(v): + d[k] = v + return d + + +def read_file_if_exists(filename: typing.Optional[str], encoding=None) -> typing.Optional[str]: + """ + Reads the contents of the file if passed a path. Otherwise, returns None. + + :param filename: The file path to load + :param encoding: The encoding to use when reading the file. + :return: The contents of the file as a string or None. + """ + if not filename: + return None + + filename = pathlib.Path(filename) + logger.debug(f"Reading file contents from [{filename}] with current directory [{os.getcwd()}].") + return filename.read_text(encoding=encoding) diff --git a/flytekit/flytekit/configuration/internal.py b/flytekit/flytekit/configuration/internal.py new file mode 100644 index 0000000000..2f28381782 --- /dev/null +++ b/flytekit/flytekit/configuration/internal.py @@ -0,0 +1,204 @@ +import configparser +import datetime +import typing + +from flytekit.configuration.file import ConfigEntry, ConfigFile, LegacyConfigEntry, YamlConfigEntry + + +class Images(object): + @staticmethod + def get_specified_images(cfg: typing.Optional[ConfigFile]) -> typing.Dict[str, str]: + """ + This section should contain options, where the option name is the friendly name of the image and the corresponding + value is actual FQN of the image. Example of how the section is structured + [images] + my_image1=docker.io/flyte:tag + # Note that the tag is optional. If not specified it will be the default version identifier specified + my_image2=docker.io/flyte + + :returns a dictionary of name: image Version is optional + """ + images: typing.Dict[str, str] = {} + if cfg is None: + return images + + if cfg.legacy_config: + try: + image_names = cfg.legacy_config.options("images") + except configparser.NoSectionError: + return {} + if image_names: + for i in image_names: + images[str(i)] = cfg.legacy_config.get("images", i) + return images + if cfg.yaml_config: + return cfg.yaml_config.get("images", images) + + +class AWS(object): + SECTION = "aws" + S3_ENDPOINT = ConfigEntry(LegacyConfigEntry(SECTION, "endpoint"), YamlConfigEntry("storage.connection.endpoint")) + S3_ACCESS_KEY_ID = ConfigEntry( + LegacyConfigEntry(SECTION, "access_key_id"), YamlConfigEntry("storage.connection.access-key") + ) + S3_SECRET_ACCESS_KEY = ConfigEntry( + LegacyConfigEntry(SECTION, "secret_access_key"), YamlConfigEntry("storage.connection.secret-key") + ) + ENABLE_DEBUG = ConfigEntry(LegacyConfigEntry(SECTION, "enable_debug", bool)) + RETRIES = ConfigEntry(LegacyConfigEntry(SECTION, "retries", int)) + BACKOFF_SECONDS = ConfigEntry( + LegacyConfigEntry(SECTION, "backoff_seconds", datetime.timedelta), + transform=lambda x: datetime.timedelta(seconds=int(x)), + ) + + +class GCP(object): + SECTION = "gcp" + GSUTIL_PARALLELISM = ConfigEntry(LegacyConfigEntry(SECTION, "gsutil_parallelism", bool)) + + +class AZURE(object): + SECTION = "azure" + STORAGE_ACCOUNT_NAME = ConfigEntry(LegacyConfigEntry(SECTION, "storage_account_name")) + STORAGE_ACCOUNT_KEY = ConfigEntry(LegacyConfigEntry(SECTION, "storage_account_key")) + TENANT_ID = ConfigEntry(LegacyConfigEntry(SECTION, "tenant_id")) + CLIENT_ID = ConfigEntry(LegacyConfigEntry(SECTION, "client_id")) + CLIENT_SECRET = ConfigEntry(LegacyConfigEntry(SECTION, "client_secret")) + + +class Local(object): + SECTION = "local" + CACHE_ENABLED = ConfigEntry(LegacyConfigEntry(SECTION, "cache_enabled", bool)) + CACHE_OVERWRITE = ConfigEntry(LegacyConfigEntry(SECTION, "cache_overwrite", bool)) + + +class Credentials(object): + SECTION = "credentials" + COMMAND = ConfigEntry(LegacyConfigEntry(SECTION, "command", list), YamlConfigEntry("admin.command", list)) + """ + This command is executed to return a token using an external process. + """ + + PROXY_COMMAND = ConfigEntry( + LegacyConfigEntry(SECTION, "proxy_command", list), YamlConfigEntry("admin.proxyCommand", list) + ) + """ + This command is executed to return a token for authorization with a proxy in front of Flyte using an external process. + """ + + CLIENT_ID = ConfigEntry(LegacyConfigEntry(SECTION, "client_id"), YamlConfigEntry("admin.clientId")) + """ + This is the public identifier for the app which handles authorization for a Flyte deployment. + More details here: https://www.oauth.com/oauth2-servers/client-registration/client-id-secret/. + """ + + CLIENT_CREDENTIALS_SECRET = ConfigEntry(LegacyConfigEntry(SECTION, "client_secret")) + """ + Used for basic auth, which is automatically called during pyflyte. This will allow the Flyte engine to read the + password directly from the environment variable. Note that this is less secure! Please only use this if mounting the + secret as a file is impossible. + """ + + CLIENT_CREDENTIALS_SECRET_LOCATION = ConfigEntry( + LegacyConfigEntry(SECTION, "client_secret_location"), YamlConfigEntry("admin.clientSecretLocation") + ) + """ + Used for basic auth, which is automatically called during pyflyte. This will allow the Flyte engine to read the + password from a mounted file. + """ + + CLIENT_CREDENTIALS_SECRET_ENV_VAR = ConfigEntry( + LegacyConfigEntry(SECTION, "client_secret_env_var"), YamlConfigEntry("admin.clientSecretEnvVar") + ) + """ + Used for basic auth, which is automatically called during pyflyte. This will allow the Flyte engine to read the + password from a mounted environment variable. + """ + + SCOPES = ConfigEntry(LegacyConfigEntry(SECTION, "scopes", list), YamlConfigEntry("admin.scopes", list)) + """ + This setting can be used to manually pass in scopes into authenticator flows - eg.) for Auth0 compatibility + """ + + AUTH_MODE = ConfigEntry(LegacyConfigEntry(SECTION, "auth_mode"), YamlConfigEntry("admin.authType")) + """ + The auth mode defines the behavior used to request and refresh credentials. The currently supported modes include: + - 'standard' or 'Pkce': This uses the pkce-enhanced authorization code flow by opening a browser window to initiate + credentials access. + - "DeviceFlow": This uses the Device Authorization Flow + - 'basic', 'client_credentials' or 'clientSecret': This uses symmetric key auth in which the end user enters a + client id and a client secret and public key encryption is used to facilitate authentication. + - None: No auth will be attempted. + """ + + +class Platform(object): + SECTION = "platform" + URL = ConfigEntry( + LegacyConfigEntry(SECTION, "url"), YamlConfigEntry("admin.endpoint"), lambda x: x.replace("dns:///", "") + ) + INSECURE = ConfigEntry(LegacyConfigEntry(SECTION, "insecure", bool), YamlConfigEntry("admin.insecure", bool)) + INSECURE_SKIP_VERIFY = ConfigEntry( + LegacyConfigEntry(SECTION, "insecure_skip_verify", bool), YamlConfigEntry("admin.insecureSkipVerify", bool) + ) + CONSOLE_ENDPOINT = ConfigEntry(LegacyConfigEntry(SECTION, "console_endpoint"), YamlConfigEntry("console.endpoint")) + CA_CERT_FILE_PATH = ConfigEntry( + LegacyConfigEntry(SECTION, "ca_cert_file_path"), YamlConfigEntry("admin.caCertFilePath") + ) + HTTP_PROXY_URL = ConfigEntry(LegacyConfigEntry(SECTION, "http_proxy_url"), YamlConfigEntry("admin.httpProxyURL")) + + +class LocalSDK(object): + SECTION = "sdk" + WORKFLOW_PACKAGES = ConfigEntry(LegacyConfigEntry(SECTION, "workflow_packages", list)) + """ + This is a comma-delimited list of packages that SDK tools will use to discover entities for the purpose of registration + and execution of entities. + """ + + LOCAL_SANDBOX = ConfigEntry(LegacyConfigEntry(SECTION, "local_sandbox")) + """ + This is the path where SDK will place files during local executions and testing. The SDK will not automatically + clean up data in these directories. + """ + + LOGGING_LEVEL = ConfigEntry(LegacyConfigEntry(SECTION, "logging_level", int)) + """ + This is the default logging level for the Python logging library and will be set before user code runs. + Note that this configuration is special in that it is a runtime setting, not a compile time setting. This is the only + runtime option in this file. + + TODO delete the one from internal config + """ + + +class Secrets(object): + SECTION = "secrets" + # Secrets management + ENV_PREFIX = ConfigEntry(LegacyConfigEntry(SECTION, "env_prefix")) + """ + This is the prefix that will be used to lookup for injected secrets at runtime. This can be overridden to using + FLYTE_SECRETS_ENV_PREFIX variable + """ + + DEFAULT_DIR = ConfigEntry(LegacyConfigEntry(SECTION, "default_dir")) + """ + This is the default directory that will be used to find secrets as individual files under. This can be overridden using + FLYTE_SECRETS_DEFAULT_DIR. + """ + + FILE_PREFIX = ConfigEntry(LegacyConfigEntry(SECTION, "file_prefix")) + """ + This is the prefix for the file in the default dir. + """ + + +class StatsD(object): + SECTION = "secrets" + # StatsD Config flags should ideally be controlled at the platform level and not through flytekit's config file. + # They are meant to allow administrators to control certain behavior according to how the system is configured. + + HOST = ConfigEntry(LegacyConfigEntry(SECTION, "host")) + PORT = ConfigEntry(LegacyConfigEntry(SECTION, "port", int)) + DISABLED = ConfigEntry(LegacyConfigEntry(SECTION, "disabled", bool)) + DISABLE_TAGS = ConfigEntry(LegacyConfigEntry(SECTION, "disable_tags", bool)) diff --git a/flytekit/flytekit/configuration/plugin.py b/flytekit/flytekit/configuration/plugin.py new file mode 100644 index 0000000000..710c00cc0d --- /dev/null +++ b/flytekit/flytekit/configuration/plugin.py @@ -0,0 +1,117 @@ +"""Defines a plugin API allowing other libraries to modify the behavior of flytekit. + +Libraries can register by defining an object that follows the same API as FlytekitPlugin +and providing an entrypoint with the group name "flytekit.plugin". In `setuptools`, +you can specific them with: + +```python +setup(entry_points={ + "flytekit.configuration.plugin": ["my_plugin=my_module:MyCustomPlugin"] +}) +``` + +or in pyproject.toml: + +```toml +[project.entry-points."flytekit.configuration.plugin"] +my_plugin = "my_module:MyCustomPlugin" +``` +""" +from typing import Optional, Protocol, runtime_checkable + +from click import Group +from importlib_metadata import entry_points + +from flytekit.configuration import Config, get_config_file +from flytekit.loggers import logger +from flytekit.remote import FlyteRemote + + +@runtime_checkable +class FlytekitPluginProtocol(Protocol): + @staticmethod + def get_remote( + config: Optional[str], project: str, domain: str, data_upload_location: Optional[str] = None + ) -> FlyteRemote: + """Get FlyteRemote object for CLI session.""" + + @staticmethod + def configure_pyflyte_cli(main: Group) -> Group: + """Configure pyflyte's CLI.""" + + @staticmethod + def secret_requires_group() -> bool: + """Return True if secrets require group entry.""" + + @staticmethod + def get_default_image() -> Optional[str]: + """Get default image. Return None to use the images from flytekit.configuration.DefaultImages""" + + @staticmethod + def get_auth_success_html(endpoint: str) -> Optional[str]: + """Get default success html for auth. Return None to use flytekit's default success html.""" + + +class FlytekitPlugin: + @staticmethod + def get_remote( + config: Optional[str], project: str, domain: str, data_upload_location: Optional[str] = None + ) -> FlyteRemote: + """Get FlyteRemote object for CLI session.""" + cfg_file = get_config_file(config) + if cfg_file is None: + cfg_obj = Config.for_sandbox() + logger.info("No config files found, creating remote with sandbox config") + else: # pragma: no cover + cfg_obj = Config.auto(config) + logger.info(f"Creating remote with config {cfg_obj}" + (f" with file {config}" if config else "")) + return FlyteRemote( + cfg_obj, default_project=project, default_domain=domain, data_upload_location=data_upload_location + ) + + @staticmethod + def configure_pyflyte_cli(main: Group) -> Group: + """Configure pyflyte's CLI.""" + return main + + @staticmethod + def secret_requires_group() -> bool: + """Return True if secrets require group entry.""" + return True + + @staticmethod + def get_default_image() -> Optional[str]: + """Get default image. Return None to use the images from flytekit.configuration.DefaultImages""" + return None + + @staticmethod + def get_auth_success_html(endpoint: str) -> Optional[str]: + """Get default success html. Return None to use flytekit's default success html.""" + return None + + +def _get_plugin_from_entrypoint(): + """Get plugin from entrypoint.""" + group = "flytekit.configuration.plugin" + plugins = list(entry_points(group=group)) + + if not plugins: + return FlytekitPlugin + + if len(plugins) >= 2: + plugin_names = [p.name for p in plugins] + raise ValueError( + f"Multiple plugins installed: {plugin_names}. flytekit only supports one installed plugin at a time." + ) + + plugin_to_load = plugins[0] + logger.info(f"Loading plugin: {plugin_to_load.name}") + return plugin_to_load.load() + + +_GLOBAL_CONFIG = {"plugin": _get_plugin_from_entrypoint()} + + +def get_plugin(): + """Get current plugin""" + return _GLOBAL_CONFIG["plugin"] diff --git a/flytekit/flytekit/core/__init__.py b/flytekit/flytekit/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/core/annotation.py b/flytekit/flytekit/core/annotation.py new file mode 100644 index 0000000000..b4a70a6469 --- /dev/null +++ b/flytekit/flytekit/core/annotation.py @@ -0,0 +1,30 @@ +from typing import Any, Dict + + +class FlyteAnnotation: + """A core object to add arbitrary annotations to flyte types. + + This metadata is ingested as a python dictionary and will be serialized + into fields on the flyteidl type literals. This data is not accessible at + runtime but rather can be retrieved from flyteadmin for custom presentation + of typed parameters. + + Flytekit expects to receive a maximum of one `FlyteAnnotation` object + within each typehint. + + For a task definition: + + .. code-block:: python + + @task + def x(a: typing.Annotated[int, FlyteAnnotation({"foo": {"bar": 1}})]): + return + + """ + + def __init__(self, data: Dict[str, Any]): + self._data = data + + @property + def data(self): + return self._data diff --git a/flytekit/flytekit/core/array_node_map_task.py b/flytekit/flytekit/core/array_node_map_task.py new file mode 100644 index 0000000000..5d10cadd45 --- /dev/null +++ b/flytekit/flytekit/core/array_node_map_task.py @@ -0,0 +1,398 @@ +# TODO: has to support the SupportsNodeCreation protocol +import functools +import hashlib +import logging +import math +import os # TODO: use flytekit logger +from contextlib import contextmanager +from typing import Any, Dict, List, Optional, Set, Union, cast + +from flytekit.configuration import SerializationSettings +from flytekit.core import tracker +from flytekit.core.base_task import PythonTask, TaskResolverMixin +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, PythonInstanceTask +from flytekit.core.utils import timeit +from flytekit.exceptions import scopes as exception_scopes +from flytekit.loggers import logger +from flytekit.models.array_job import ArrayJob +from flytekit.models.core.workflow import NodeMetadata +from flytekit.models.interface import Variable +from flytekit.models.task import Container, K8sPod, Sql, Task +from flytekit.tools.module_loader import load_object_from_module + + +class ArrayNodeMapTask(PythonTask): + def __init__( + self, + # TODO: add support for other Flyte entities + python_function_task: Union[PythonFunctionTask, PythonInstanceTask, functools.partial], + concurrency: Optional[int] = None, + min_successes: Optional[int] = None, + min_success_ratio: Optional[float] = None, + bound_inputs: Optional[Set[str]] = None, + **kwargs, + ): + """ + :param python_function_task: The task to be executed in parallel + :param concurrency: The number of parallel executions to run + :param min_successes: The minimum number of successful executions + :param min_success_ratio: The minimum ratio of successful executions + :param bound_inputs: The set of inputs that should be bound to the map task + :param kwargs: Additional keyword arguments to pass to the base class + """ + 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("Cannot use a partial task with lists as inputs") + self._partial = python_function_task + actual_task = self._partial.func + else: + actual_task = python_function_task + + # TODO: add support for other Flyte entities + if not (isinstance(actual_task, PythonFunctionTask) or isinstance(actual_task, PythonInstanceTask)): + raise ValueError("Only PythonFunctionTask and PythonInstanceTask are supported in map tasks.") + + n_outputs = len(actual_task.python_interface.outputs) + if n_outputs > 1: + raise ValueError("Only tasks with a single output are supported in map tasks.") + + self._bound_inputs: Set[str] = bound_inputs or set(bound_inputs) if bound_inputs else set() + if self._partial: + self._bound_inputs.update(self._partial.keywords.keys()) + + # Transform the interface to List[Optional[T]] in case `min_success_ratio` is set + output_as_list_of_optionals = min_success_ratio is not None and min_success_ratio != 1 and n_outputs == 1 + collection_interface = transform_interface_to_list_interface( + actual_task.python_interface, self._bound_inputs, output_as_list_of_optionals + ) + + self._run_task: Union[PythonFunctionTask, PythonInstanceTask] = actual_task # type: ignore + if isinstance(actual_task, PythonInstanceTask): + mod = actual_task.task_type + f = actual_task.lhs + else: + _, mod, f, _ = tracker.extract_task_module(cast(PythonFunctionTask, actual_task).task_function) + sorted_bounded_inputs = ",".join(sorted(self._bound_inputs)) + h = hashlib.md5( + f"{sorted_bounded_inputs}{concurrency}{min_successes}{min_success_ratio}".encode("utf-8") + ).hexdigest() + self._name = f"{mod}.map_{f}_{h}-arraynode" + + self._cmd_prefix: Optional[List[str]] = None + self._concurrency: Optional[int] = concurrency + self._min_successes: Optional[int] = min_successes + self._min_success_ratio: Optional[float] = min_success_ratio + self._collection_interface = collection_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=self.name, + interface=collection_interface, + task_type=self._run_task.task_type, + task_config=None, + task_type_version=1, + **kwargs, + ) + + @property + def name(self) -> str: + return self._name + + @property + def python_interface(self): + return self._collection_interface + + def construct_node_metadata(self) -> NodeMetadata: + # TODO: add support for other Flyte entities + return NodeMetadata( + name=self.name, + ) + + @property + def min_success_ratio(self) -> Optional[float]: + return self._min_success_ratio + + @property + def min_successes(self) -> Optional[int]: + return self._min_successes + + @property + def concurrency(self) -> Optional[int]: + return self._concurrency + + @property + def python_function_task(self) -> Union[PythonFunctionTask, PythonInstanceTask]: + return self._run_task + + @property + def bound_inputs(self) -> Set[str]: + return self._bound_inputs + + @contextmanager + def prepare_target(self): + """ + Alters the underlying run_task command to modify it for map task execution and then resets it after. + """ + self.python_function_task.set_command_fn(self.get_command) + try: + yield + finally: + self.python_function_task.reset_command_fn() + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + return ArrayJob(parallelism=self._concurrency, min_success_ratio=self._min_success_ratio).to_dict() + + def get_config(self, settings: SerializationSettings) -> Optional[Dict[str, str]]: + return self.python_function_task.get_config(settings) + + def get_container(self, settings: SerializationSettings) -> Container: + with self.prepare_target(): + return self.python_function_task.get_container(settings) + + def get_k8s_pod(self, settings: SerializationSettings) -> K8sPod: + with self.prepare_target(): + return self.python_function_task.get_k8s_pod(settings) + + def get_sql(self, settings: SerializationSettings) -> Sql: + with self.prepare_target(): + return self.python_function_task.get_sql(settings) + + def get_command(self, settings: SerializationSettings) -> List[str]: + """ + TODO ADD bound variables to the resolver. Maybe we need a different resolver? + """ + mt = ArrayNodeMapTaskResolver() + container_args = [ + "pyflyte-map-execute", + "--inputs", + "{{.input}}", + "--output-prefix", + "{{.outputPrefix}}", + "--raw-output-data-prefix", + "{{.rawOutputDataPrefix}}", + "--checkpoint-path", + "{{.checkpointOutputPrefix}}", + "--prev-checkpoint", + "{{.prevCheckpointPrefix}}", + "--experimental", + "--resolver", + mt.name(), + "--", + *mt.loader_args(settings, self), + ] + + if self._cmd_prefix: + return self._cmd_prefix + container_args + return container_args + + def set_command_prefix(self, cmd: Optional[List[str]]): + self._cmd_prefix = cmd + + 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: + return self._execute_map_task(ctx, **kwargs) + + return self._raw_execute(**kwargs) + + def _execute_map_task(self, _: FlyteContext, **kwargs) -> Any: + task_index = self._compute_array_job_index() + map_task_inputs = {} + for k in self.interface.inputs.keys(): + 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.python_function_task.execute)(**map_task_inputs) + + @staticmethod + def _compute_array_job_index() -> int: + """ + Computes the absolute index of the current array job. This is determined by summing the compute-environment-specific + environment variable and the offset (if one's set). The offset will be set and used when the user request that the + job runs in a number of slots less than the size of the input. + """ + return int(os.environ.get("BATCH_JOB_ARRAY_INDEX_OFFSET", "0")) + int( + os.environ.get(os.environ.get("BATCH_JOB_ARRAY_INDEX_VAR_NAME", "0"), "0") + ) + + @property + def _outputs_interface(self) -> Dict[Any, Variable]: + """ + We override this method from PythonTask because the dispatch_execute method uses this + interface to construct outputs. Each instance of an container_array task will however produce outputs + according to the underlying run_task interface and the array plugin handler will actually create a collection + from these individual outputs as the final output value. + """ + + ctx = FlyteContextManager.current_context() + if ctx.execution_state is not None and ctx.execution_state.mode == ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION: + # In workflow execution mode we actually need to use the parent (mapper) task output interface. + return self.interface.outputs + return self.python_function_task.interface.outputs + + 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 + according to the underlying run_task interface and the array plugin handler will actually create a collection + from these individual outputs as the final output value. + """ + ctx = FlyteContextManager.current_context() + if ctx.execution_state and ctx.execution_state.is_local_execution(): + # In workflow execution mode we actually need to use the parent (mapper) task output interface. + return self._python_interface.outputs[k] + return self.python_function_task.python_interface.outputs[k] + + def _raw_execute(self, **kwargs) -> Any: + """ + This is called during locally run executions. Unlike array task execution on the Flyte platform, _raw_execute + produces the full output collection. + """ + outputs_expected = True + if not self.interface.outputs: + outputs_expected = False + outputs = [] + + mapped_tasks_count = 0 + if self._run_task.interface.inputs.items(): + for k in self._run_task.interface.inputs.keys(): + v = kwargs[k] + if isinstance(v, list) and k not in self.bound_inputs: + mapped_tasks_count = len(v) + break + + failed_count = 0 + min_successes = mapped_tasks_count + if self._min_successes: + min_successes = self._min_successes + elif self._min_success_ratio: + min_successes = math.ceil(min_successes * self._min_success_ratio) + + for i in range(mapped_tasks_count): + single_instance_inputs = {} + for k in self.interface.inputs.keys(): + 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] + try: + o = exception_scopes.user_entry_point(self._run_task.execute)(**single_instance_inputs) + if outputs_expected: + outputs.append(o) + except Exception as exc: + outputs.append(None) + failed_count += 1 + if mapped_tasks_count - failed_count < min_successes: + logger.error("The number of successful tasks is lower than the minimum ratio") + raise exc + + return outputs + + +def map_task( + task_function: PythonFunctionTask, + concurrency: int = 0, + # TODO why no min_successes? + min_success_ratio: float = 1.0, + **kwargs, +): + """Map task that uses the ``ArrayNode`` construct.. + + .. important:: + + This is an experimental drop-in replacement for :py:func:`~flytekit.map_task`. + + :param task_function: 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. If the size of the input exceeds the concurrency value, then multiple batches will be run serially until + all inputs are processed. If left unspecified, this means unbounded concurrency. + :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. + """ + return ArrayNodeMapTask(task_function, concurrency=concurrency, min_success_ratio=min_success_ratio, **kwargs) + + +class ArrayNodeMapTaskResolver(tracker.TrackedInstance, TaskResolverMixin): + """ + Special resolver that is used for ArrayNodeMapTasks. + This exists because it is possible that ArrayNodeMapTasks 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 ArrayNodeMapTaskResolver that records the "bound vars" + and then at runtime reconstructs the interface with this knowledge + """ + + def name(self) -> str: + return "ArrayNodeMapTaskResolver" + + @timeit("Load map task") + def load_task(self, loader_args: List[str], max_concurrency: int = 0) -> ArrayNodeMapTask: + """ + 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 ArrayNodeMapTask( + python_function_task=_task_def, max_concurrency=max_concurrency, bound_inputs=bound_inputs + ) + + def loader_args(self, settings: SerializationSettings, t: ArrayNodeMapTask) -> List[str]: # type:ignore + return [ + "vars", + f'{",".join(sorted(t.bound_inputs))}', + "resolver", + t.python_function_task.task_resolver.location, + *t.python_function_task.task_resolver.loader_args(settings, t.python_function_task), + ] + + def get_all_tasks(self) -> List[Task]: + raise NotImplementedError("MapTask resolver cannot return every instance of the map task") diff --git a/flytekit/flytekit/core/artifact.py b/flytekit/flytekit/core/artifact.py new file mode 100644 index 0000000000..6c709f59a1 --- /dev/null +++ b/flytekit/flytekit/core/artifact.py @@ -0,0 +1,515 @@ +from __future__ import annotations + +import datetime +import typing +from datetime import timedelta +from typing import Optional, Union + +from flyteidl.core import artifact_id_pb2 as art_id +from google.protobuf.timestamp_pb2 import Timestamp + +TIME_PARTITION_KWARG = "time_partition" + + +class InputsBase(object): + """ + A class to provide better partition semantics + Used for invoking an Artifact to bind partition keys to input values. + If there's a good reason to use a metaclass in the future we can, but a simple instance suffices for now + """ + + def __getattr__(self, name: str) -> art_id.InputBindingData: + return art_id.InputBindingData(var=name) + + +Inputs = InputsBase() + + +class ArtifactIDSpecification(object): + """ + This is a special object that helps specify how Artifacts are to be created. See the comment in the + call function of the main Artifact class. Also see the handling code in transform_variable_map for more + information. There's a limited set of information that we ultimately need in a TypedInterface, so it + doesn't make sense to carry the full Artifact object around. This object should be sufficient, despite + having a pointer to the main artifact. + """ + + def __init__(self, a: Artifact): + self.artifact = a + self.partitions: Optional[Partitions] = None + self.time_partition: Optional[TimePartition] = None + + # todo: add time partition arg hint + def __call__(self, *args, **kwargs): + return self.bind_partitions(*args, **kwargs) + + def bind_partitions(self, *args, **kwargs) -> ArtifactIDSpecification: + # See the parallel function in the main Artifact class for more information. + if len(args) > 0: + raise ValueError("Cannot set partition values by position") + + if TIME_PARTITION_KWARG in kwargs: + if not self.artifact.time_partitioned: + raise ValueError("Cannot bind time partition to non-time partitioned artifact") + p = kwargs[TIME_PARTITION_KWARG] + if isinstance(p, datetime.datetime): + t = Timestamp() + t.FromDatetime(p) + self.time_partition = TimePartition(value=art_id.LabelValue(time_value=t)) + elif isinstance(p, art_id.InputBindingData): + self.time_partition = TimePartition(value=art_id.LabelValue(input_binding=p)) + else: + raise ValueError(f"Time partition needs to be input binding data or static string, not {p}") + # Given the context, shouldn't need to set further reference_artifacts. + + del kwargs[TIME_PARTITION_KWARG] + + if len(kwargs) > 0: + p = Partitions(None) + # k is the partition key, v should be static, or an input to the task or workflow + for k, v in kwargs.items(): + if not self.artifact.partition_keys or k not in self.artifact.partition_keys: + raise ValueError(f"Partition key {k} not found in {self.artifact.partition_keys}") + if isinstance(v, art_id.InputBindingData): + p.partitions[k] = Partition(art_id.LabelValue(input_binding=v), name=k) + elif isinstance(v, str): + p.partitions[k] = Partition(art_id.LabelValue(static_value=v), name=k) + else: + raise ValueError(f"Partition key {k} needs to be input binding data or static string, not {v}") + # Given the context, shouldn't need to set further reference_artifacts. + self.partitions = p + + return self + + def to_partial_artifact_id(self) -> art_id.ArtifactID: + # This function should only be called by transform_variable_map + artifact_id = self.artifact.to_id_idl() + # Use the partitions from this object, but replacement is not allowed by protobuf, so generate new object + p = Serializer.partitions_to_idl(self.partitions) + tp = None + if self.artifact.time_partitioned: + if not self.time_partition: + raise ValueError( + f"Artifact {artifact_id.artifact_key} requires a time partition, but it hasn't been bound." + ) + tp = self.time_partition.to_flyte_idl() + + if self.artifact.partition_keys: + required = len(self.artifact.partition_keys) + fulfilled = len(p.value) if p else 0 + if required != fulfilled: + raise ValueError( + f"Artifact {artifact_id.artifact_key} requires {required} partitions, but only {fulfilled} are " + f"bound." + ) + artifact_id = art_id.ArtifactID( + artifact_key=artifact_id.artifact_key, + partitions=p, + time_partition=tp, + version=artifact_id.version, # this should almost never be set since setting it + # hardcodes the query to one version + ) + return artifact_id + + +class ArtifactQuery(object): + def __init__( + self, + artifact: Artifact, + name: str, + project: Optional[str] = None, + domain: Optional[str] = None, + time_partition: Optional[TimePartition] = None, + partitions: Optional[Partitions] = None, + tag: Optional[str] = None, + ): + if not name: + raise ValueError("Cannot create query without name") + + # So normally, if you just do MyData.query(partitions="region": Inputs.region), it will just + # use the input value to fill in the partition. But if you do + # MyData.query(region=OtherArtifact.partitions.region) + # then you now have a dependency on the other artifact. This list keeps track of all the other Artifacts you've + # referenced. + self.artifact = artifact + bindings: typing.List[Artifact] = [] + if time_partition: + if time_partition.reference_artifact and time_partition.reference_artifact is not artifact: + bindings.append(time_partition.reference_artifact) + if partitions and partitions.partitions: + for k, v in partitions.partitions.items(): + if v.reference_artifact and v.reference_artifact is not artifact: + bindings.append(v.reference_artifact) + + self.name = name + self.project = project + self.domain = domain + self.time_partition = time_partition + self.partitions = partitions + self.tag = tag + self.bindings = bindings + + def to_flyte_idl( + self, + **kwargs, + ) -> art_id.ArtifactQuery: + return Serializer.artifact_query_to_idl(self, **kwargs) + + +class TimePartition(object): + def __init__( + self, + value: Union[art_id.LabelValue, art_id.InputBindingData, str, datetime.datetime, None], + op: Optional[str] = None, + other: Optional[timedelta] = None, + ): + if isinstance(value, str): + raise ValueError(f"value to a time partition shouldn't be a str {value}") + elif isinstance(value, datetime.datetime): + t = Timestamp() + t.FromDatetime(value) + value = art_id.LabelValue(time_value=t) + elif isinstance(value, art_id.InputBindingData): + value = art_id.LabelValue(input_binding=value) + # else should already be a LabelValue or None + self.value: art_id.LabelValue = value + self.op = op + self.other = other + self.reference_artifact: Optional[Artifact] = None + + def __add__(self, other: timedelta) -> TimePartition: + tp = TimePartition(self.value, op="+", other=other) + tp.reference_artifact = self.reference_artifact + return tp + + def __sub__(self, other: timedelta) -> TimePartition: + tp = TimePartition(self.value, op="-", other=other) + tp.reference_artifact = self.reference_artifact + return tp + + def to_flyte_idl(self, **kwargs) -> Optional[art_id.TimePartition]: + return Serializer.time_partition_to_idl(self, **kwargs) + + +class Partition(object): + def __init__(self, value: Optional[art_id.LabelValue], name: str): + self.name = name + self.value = value + self.reference_artifact: Optional[Artifact] = None + + +class Partitions(object): + def __init__(self, partitions: Optional[typing.Mapping[str, Union[str, art_id.InputBindingData, Partition]]]): + self._partitions = {} + if partitions: + for k, v in partitions.items(): + if isinstance(v, Partition): + self._partitions[k] = v + elif isinstance(v, art_id.InputBindingData): + self._partitions[k] = Partition(art_id.LabelValue(input_binding=v), name=k) + else: + self._partitions[k] = Partition(art_id.LabelValue(static_value=v), name=k) + self.reference_artifact: Optional[Artifact] = None + + @property + def partitions(self) -> Optional[typing.Dict[str, Partition]]: + return self._partitions + + def set_reference_artifact(self, artifact: Artifact): + self.reference_artifact = artifact + if self.partitions: + for p in self.partitions.values(): + p.reference_artifact = artifact + + def __getattr__(self, item): + if self.partitions and item in self.partitions: + return self.partitions[item] + raise AttributeError(f"Partition {item} not found in {self}") + + def to_flyte_idl(self, **kwargs) -> Optional[art_id.Partitions]: + return Serializer.partitions_to_idl(self, **kwargs) + + +class Artifact(object): + """ + An Artifact is effectively just a metadata layer on top of data that exists in Flyte. Most data of interest + will be the output of tasks and workflows. The other category is user uploads. + + This Python class has limited purpose, as a way for users to specify that tasks/workflows create Artifacts + and the manner (i.e. name, partitions) in which they are created. + + Control creation parameters at task/workflow execution time :: + + @task + def t1() -> Annotated[nn.Module, Artifact(name="my.artifact.name")]: + ... + """ + + def __init__( + self, + project: Optional[str] = None, + domain: Optional[str] = None, + name: Optional[str] = None, + version: Optional[str] = None, + time_partitioned: bool = False, + time_partition: Optional[TimePartition] = None, + partition_keys: Optional[typing.List[str]] = None, + partitions: Optional[Union[Partitions, typing.Dict[str, str]]] = None, + ): + """ + :param project: Should not be directly user provided, the project/domain will come from the project/domain of + the execution that produced the output. These values will be filled in automatically when retrieving however. + :param domain: See above. + :param name: The name of the Artifact. This should be user provided. + :param version: Version of the Artifact, typically the execution ID, plus some additional entropy. + Not user provided. + :param time_partitioned: Whether or not this Artifact will have a time partition. + :param partition_keys: This is a list of keys that will be used to partition the Artifact. These are not the + values. Values are set via a () on the artifact and will end up in the partition_values field. + :param partitions: This is a dictionary of partition keys to values. + """ + if not name: + raise ValueError("Can't instantiate an Artifact without a name.") + self.project = project + self.domain = domain + self.name = name + self.version = version + self.time_partitioned = time_partitioned + self._time_partition = None + if time_partition: + self._time_partition = time_partition + self._time_partition.reference_artifact = self + self.partition_keys = partition_keys + self._partitions: Optional[Partitions] = None + if partitions: + if isinstance(partitions, dict): + self._partitions = Partitions(partitions) + self.partition_keys = list(partitions.keys()) + elif isinstance(partitions, Partitions): + self._partitions = partitions + if not partitions.partitions: + raise ValueError("Partitions must be non-empty") + self.partition_keys = list(partitions.partitions.keys()) + else: + raise ValueError(f"Partitions must be a dict or Partitions object, not {type(partitions)}") + self._partitions.set_reference_artifact(self) + if not partitions and partition_keys: + # this should be the only time where we create Partition objects with None + p = {k: Partition(None, name=k) for k in partition_keys} + self._partitions = Partitions(p) + self._partitions.set_reference_artifact(self) + + def __call__(self, *args, **kwargs) -> ArtifactIDSpecification: + """ + This __call__ should only ever happen in the context of a task or workflow's output, to be + used in an Annotated[] call. The other styles will go through different call functions. + """ + # Can't guarantee the order in which time/non-time partitions are bound so create the helper + # object and invoke the function there. + partial_id = ArtifactIDSpecification(self) + return partial_id.bind_partitions(*args, **kwargs) + + @property + def partitions(self) -> Optional[Partitions]: + return self._partitions + + @property + def time_partition(self) -> TimePartition: + if not self.time_partitioned: + raise ValueError(f"Artifact {self.name} is not time partitioned") + if not self._time_partition and self.time_partitioned: + self._time_partition = TimePartition(None) + self._time_partition.reference_artifact = self + return self._time_partition + + def __str__(self): + tp_str = f" time partition={self.time_partition}\n" if self.time_partitioned else "" + return ( + f"Artifact: project={self.project}, domain={self.domain}, name={self.name}, version={self.version}\n" + f" name={self.name}\n" + f" partitions={self.partitions}\n" + f"{tp_str}" + ) + + def __repr__(self): + return self.__str__() + + def query( + self, + project: Optional[str] = None, + domain: Optional[str] = None, + time_partition: Optional[Union[datetime.datetime, TimePartition, art_id.InputBindingData]] = None, + partitions: Optional[Union[typing.Dict[str, str], Partitions]] = None, + **kwargs, + ) -> ArtifactQuery: + if self.partition_keys: + fn_args = {"project", "domain", "time_partition", "partitions", "tag"} + k = set(self.partition_keys) + if len(fn_args & k) > 0: + raise ValueError( + f"There are conflicting partition key names {fn_args ^ k}, please rename" + f" use a partitions object" + ) + if partitions and kwargs: + raise ValueError("Please either specify kwargs or a partitions object not both") + + p_obj: Optional[Partitions] = None + if kwargs: + p_obj = Partitions(kwargs) + p_obj.reference_artifact = self # only set top level + + if partitions and isinstance(partitions, dict): + p_obj = Partitions(partitions) + p_obj.reference_artifact = self # only set top level + + tp = None + if time_partition: + if isinstance(time_partition, TimePartition): + tp = time_partition + else: + tp = TimePartition(time_partition) + tp.reference_artifact = self + + tp = tp or (self.time_partition if self.time_partitioned else None) + + aq = ArtifactQuery( + artifact=self, + name=self.name, + project=project or self.project or None, + domain=domain or self.domain or None, + time_partition=tp, + partitions=p_obj or self.partitions, + ) + return aq + + @property + def concrete_artifact_id(self) -> art_id.ArtifactID: + # This property is used when you want to ensure that this is a materialized artifact, all fields are known. + if self.name is None or self.project is None or self.domain is None or self.version is None: + raise ValueError("Cannot create artifact id without name, project, domain, version") + return self.to_id_idl() + + def embed_as_query( + self, + bindings: typing.List[Artifact], + partition: Optional[str] = None, + bind_to_time_partition: Optional[bool] = None, + expr: Optional[str] = None, + ) -> art_id.ArtifactQuery: + """ + This should only be called in the context of a Trigger + :param bindings: The list of artifacts in trigger_on + :param partition: Can embed a time partition + :param bind_to_time_partition: Set to true if you want to bind to a time partition + :param expr: Only valid if there's a time partition. + """ + # Find self in the list, raises ValueError if not there. + idx = bindings.index(self) + aq = art_id.ArtifactQuery( + binding=art_id.ArtifactBindingData( + index=idx, + partition_key=partition, + bind_to_time_partition=bind_to_time_partition, + transform=str(expr) if expr and (partition or bind_to_time_partition) else None, + ) + ) + + return aq + + def to_id_idl(self) -> art_id.ArtifactID: + """ + Converts this object to the IDL representation. + This is here instead of translator because it's in the interface, a relatively simple proto object + that's exposed to the user. + """ + p = Serializer.partitions_to_idl(self.partitions) + tp = Serializer.time_partition_to_idl(self.time_partition) if self.time_partitioned else None + + i = art_id.ArtifactID( + artifact_key=art_id.ArtifactKey( + project=self.project, + domain=self.domain, + name=self.name, + ), + version=self.version, + partitions=p, + time_partition=tp, + ) + + return i + + +class ArtifactSerializationHandler(typing.Protocol): + """ + This protocol defines the interface for serializing artifact-related entities down to Flyte IDL. + """ + + def partitions_to_idl(self, p: Optional[Partitions], **kwargs) -> Optional[art_id.Partitions]: + ... + + def time_partition_to_idl(self, tp: Optional[TimePartition], **kwargs) -> Optional[art_id.TimePartition]: + ... + + def artifact_query_to_idl(self, aq: ArtifactQuery, **kwargs) -> art_id.ArtifactQuery: + ... + + +class DefaultArtifactSerializationHandler(ArtifactSerializationHandler): + def partitions_to_idl(self, p: Optional[Partitions], **kwargs) -> Optional[art_id.Partitions]: + if p and p.partitions: + pp = {} + for k, v in p.partitions.items(): + if v.value is None: + # For specifying partitions in the Variable partial id + pp[k] = art_id.LabelValue(static_value="") + else: + pp[k] = v.value + return art_id.Partitions(value=pp) + return None + + def time_partition_to_idl(self, tp: Optional[TimePartition], **kwargs) -> Optional[art_id.TimePartition]: + if tp: + return art_id.TimePartition(value=tp.value) + return None + + def artifact_query_to_idl(self, aq: ArtifactQuery, **kwargs) -> art_id.ArtifactQuery: + ak = art_id.ArtifactKey( + name=aq.name, + project=aq.project, + domain=aq.domain, + ) + + p = self.partitions_to_idl(aq.partitions) + tp = self.time_partition_to_idl(aq.time_partition) + + i = art_id.ArtifactID( + artifact_key=ak, + partitions=p, + time_partition=tp, + ) + + aq = art_id.ArtifactQuery( + artifact_id=i, + ) + + return aq + + +class Serializer(object): + serializer: ArtifactSerializationHandler = DefaultArtifactSerializationHandler() + + @classmethod + def register_serializer(cls, serializer: ArtifactSerializationHandler): + cls.serializer = serializer + + @classmethod + def partitions_to_idl(cls, p: Optional[Partitions], **kwargs) -> Optional[art_id.Partitions]: + return cls.serializer.partitions_to_idl(p, **kwargs) + + @classmethod + def time_partition_to_idl(cls, tp: TimePartition, **kwargs) -> Optional[art_id.TimePartition]: + return cls.serializer.time_partition_to_idl(tp, **kwargs) + + @classmethod + def artifact_query_to_idl(cls, aq: ArtifactQuery, **kwargs) -> art_id.ArtifactQuery: + return cls.serializer.artifact_query_to_idl(aq, **kwargs) diff --git a/flytekit/flytekit/core/base_sql_task.py b/flytekit/flytekit/core/base_sql_task.py new file mode 100644 index 0000000000..30b73223a9 --- /dev/null +++ b/flytekit/flytekit/core/base_sql_task.py @@ -0,0 +1,77 @@ +import re +from typing import Any, Dict, Optional, Tuple, Type, TypeVar + +from flytekit.core.base_task import PythonTask, TaskMetadata +from flytekit.core.interface import Interface + +T = TypeVar("T") + + +class SQLTask(PythonTask[T]): + """ + Base task types for all SQL tasks. See :py:class:`flytekit.extras.sqlite3.task.SQLite3Task` + and :py:class:`flytekitplugins.athena.task.AthenaTask` for examples of how to use it as a base class. + + .. autoclass:: flytekit.extras.sqlite3.task.SQLite3Task + :noindex: + """ + + _INPUT_REGEX = re.compile(r"({{\s*.inputs.(\w+)\s*}})", re.IGNORECASE) + + def __init__( + self, + name: str, + query_template: str, + task_config: Optional[T] = None, + task_type="sql_task", + inputs: Optional[Dict[str, Tuple[Type, Any]]] = None, + metadata: Optional[TaskMetadata] = None, + outputs: Optional[Dict[str, Type]] = None, + **kwargs, + ): + """ + This SQLTask should mostly just be used as a base class for other SQL task types and should not be used + directly. See :py:class:`flytekit.extras.sqlite3.task.SQLite3Task` + """ + super().__init__( + task_type=task_type, + name=name, + interface=Interface(inputs=inputs or {}, outputs=outputs or {}), + metadata=metadata, + task_config=task_config, + **kwargs, + ) + self._query_template = re.sub(r"\s+", " ", query_template.replace("\n", " ").replace("\t", " ")).strip() + + @property + def query_template(self) -> str: + return self._query_template + + def execute(self, **kwargs) -> Any: + raise Exception("Cannot run a SQL Task natively, please mock.") + + def get_query(self, **kwargs) -> str: + return self.interpolate_query(self.query_template, **kwargs) + + @classmethod + def interpolate_query(cls, query_template, **kwargs) -> Any: + """ + This function will fill in the query template with the provided kwargs and return the interpolated query. + Please note that when SQL tasks run in Flyte, this step is done by the task executor. + """ + modified_query = query_template + matched = set() + for match in cls._INPUT_REGEX.finditer(query_template): + expr = match.groups()[0] + var = match.groups()[1] + if var not in kwargs: + raise ValueError(f"Variable {var} in Query (part of {expr}) not found in inputs {kwargs.keys()}") + matched.add(var) + val = kwargs[var] + # str conversion should be deliberate, with right conversion for each type + modified_query = modified_query.replace(expr, str(val)) + + if len(matched) < len(kwargs.keys()): + diff = set(kwargs.keys()).difference(matched) + raise ValueError(f"Extra Inputs have no matches in query template - missing {diff}") + return modified_query diff --git a/flytekit/flytekit/core/base_task.py b/flytekit/flytekit/core/base_task.py new file mode 100644 index 0000000000..4b2628a3de --- /dev/null +++ b/flytekit/flytekit/core/base_task.py @@ -0,0 +1,792 @@ +""" +============================== +:mod:`flytekit.core.base_task` +============================== + +.. currentmodule:: flytekit.core.base_task + +.. autosummary:: + :toctree: generated/ + + kwtypes + PythonTask + Task + TaskResolverMixin + IgnoreOutputs + +""" + +import asyncio +import collections +import datetime +import inspect +import warnings +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any, Coroutine, Dict, Generic, List, Optional, OrderedDict, Tuple, Type, TypeVar, Union, cast + +from flyteidl.core import tasks_pb2 + +from flytekit.configuration import LocalConfig, SerializationSettings +from flytekit.core.context_manager import ( + ExecutionParameters, + ExecutionState, + FlyteContext, + FlyteContextManager, + FlyteEntities, +) +from flytekit.core.interface import Interface, transform_interface_to_typed_interface +from flytekit.core.local_cache import LocalTaskCache +from flytekit.core.promise import ( + Promise, + VoidPromise, + create_and_link_node, + create_task_output, + extract_obj_name, + flyte_entity_call_handler, + translate_inputs_to_literals, +) +from flytekit.core.tracker import TrackedInstance +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 +from flytekit.models import literals as _literal_models +from flytekit.models import task as _task_model +from flytekit.models.core import workflow as _workflow_model +from flytekit.models.documentation import Description, Documentation +from flytekit.models.interface import Variable +from flytekit.models.security import SecurityContext + + +def kwtypes(**kwargs) -> OrderedDict[str, Type]: + """ + This is a small helper function to convert the keyword arguments to an OrderedDict of types. + + .. code-block:: python + + kwtypes(a=int, b=str) + """ + d = collections.OrderedDict() + for k, v in kwargs.items(): + d[k] = v + return d + + +@dataclass +class TaskMetadata(object): + """ + Metadata for a Task. Things like retries and whether or not caching is turned on, and cache version are specified + here. + + See the :std:ref:`IDL ` for the protobuf definition. + + Args: + cache (bool): Indicates if caching should be enabled. See :std:ref:`Caching ` + cache_serialize (bool): Indicates if identical (ie. same inputs) instances of this task should be executed in serial when caching is enabled. See :std:ref:`Caching ` + cache_version (str): Version to be used for the cached value + interruptible (Optional[bool]): Indicates that this task can be interrupted and/or scheduled on nodes with + lower QoS guarantees that can include pre-emption. This can reduce the monetary cost executions incur at the + cost of performance penalties due to potential interruptions + deprecated (str): Can be used to provide a warning message for deprecated task. Absence or empty str indicates + that the task is active and not deprecated + retries (int): for retries=n; n > 0, on failures of this task, the task will be retried at-least n number of times. + timeout (Optional[Union[datetime.timedelta, int]]): the max amount of time for which one execution of this task + should be executed for. The execution will be terminated if the runtime exceeds the given timeout + (approximately) + pod_template_name (Optional[str]): the name of existing PodTemplate resource in the cluster which will be used in this task. + """ + + cache: bool = False + cache_serialize: bool = False + cache_version: str = "" + interruptible: Optional[bool] = None + deprecated: str = "" + retries: int = 0 + timeout: Optional[Union[datetime.timedelta, int]] = None + pod_template_name: Optional[str] = None + + def __post_init__(self): + if self.timeout: + if isinstance(self.timeout, int): + self.timeout = datetime.timedelta(seconds=self.timeout) + elif not isinstance(self.timeout, datetime.timedelta): + raise ValueError("timeout should be duration represented as either a datetime.timedelta or int seconds") + if self.cache and not self.cache_version: + raise ValueError("Caching is enabled ``cache=True`` but ``cache_version`` is not set.") + if self.cache_serialize and not self.cache: + raise ValueError("Cache serialize is enabled ``cache_serialize=True`` but ``cache`` is not enabled.") + + @property + def retry_strategy(self) -> _literal_models.RetryStrategy: + return _literal_models.RetryStrategy(self.retries) + + def to_taskmetadata_model(self) -> _task_model.TaskMetadata: + """ + Converts to _task_model.TaskMetadata + """ + from flytekit import __version__ + + return _task_model.TaskMetadata( + discoverable=self.cache, + runtime=_task_model.RuntimeMetadata( + _task_model.RuntimeMetadata.RuntimeType.FLYTE_SDK, __version__, "python" + ), + timeout=self.timeout, + retries=self.retry_strategy, + interruptible=self.interruptible, + discovery_version=self.cache_version, + deprecated_error_message=self.deprecated, + cache_serializable=self.cache_serialize, + pod_template_name=self.pod_template_name, + ) + + +class IgnoreOutputs(Exception): + """ + This exception should be used to indicate that the outputs generated by this can be safely ignored. + This is useful in case of distributed training or peer-to-peer parallel algorithms. + + For example look at Sagemaker training, e.g. + :py:class:`plugins.awssagemaker.flytekitplugins.awssagemaker.training.SagemakerBuiltinAlgorithmsTask`. + """ + + pass + + +class Task(object): + """ + The base of all Tasks in flytekit. This task is closest to the FlyteIDL TaskTemplate and captures information in + FlyteIDL specification and does not have python native interfaces associated. Refer to the derived classes for + examples of how to extend this class. + """ + + def __init__( + self, + task_type: str, + name: str, + interface: _interface_models.TypedInterface, + metadata: Optional[TaskMetadata] = None, + task_type_version=0, + security_ctx: Optional[SecurityContext] = None, + docs: Optional[Documentation] = None, + **kwargs, + ): + self._task_type = task_type + self._name = name + self._interface = interface + self._metadata = metadata if metadata else TaskMetadata() + self._task_type_version = task_type_version + self._security_ctx = security_ctx + self._docs = docs + + FlyteEntities.entities.append(self) + + @property + def interface(self) -> _interface_models.TypedInterface: + return self._interface + + @property + def metadata(self) -> TaskMetadata: + return self._metadata + + @property + def name(self) -> str: + return self._name + + @property + def task_type(self) -> str: + return self._task_type + + @property + def python_interface(self) -> Optional[Interface]: + return None + + @property + def task_type_version(self) -> int: + return self._task_type_version + + @property + def security_context(self) -> SecurityContext: + return self._security_ctx + + @property + def docs(self) -> Documentation: + return self._docs + + def get_type_for_input_var(self, k: str, v: Any) -> type: + """ + Returns the python native type for the given input variable + # TODO we could use literal type to determine this + """ + return type(v) + + def get_type_for_output_var(self, k: str, v: Any) -> type: + """ + Returns the python native type for the given output variable + # TODO we could use literal type to determine this + """ + return type(v) + + def get_input_types(self) -> Optional[Dict[str, type]]: + """ + Returns python native types for inputs. In case this is not a python native task (base class) and hence + returns a None. we could deduce the type from literal types, but that is not a required exercise + # TODO we could use literal type to determine this + """ + return None + + def local_execute( + self, ctx: FlyteContext, **kwargs + ) -> Union[Tuple[Promise], Promise, VoidPromise, Coroutine, None]: + """ + This function is used only in the local execution path and is responsible for calling dispatch execute. + Use this function when calling a task with native values (or Promises containing Flyte literals derived from + Python native values). + """ + # Unwrap the kwargs values. After this, we essentially have a LiteralMap + # The reason why we need to do this is because the inputs during local execute can be of 2 types + # - Promises or native constants + # 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 + 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 + local_config = LocalConfig.auto() + if self.metadata.cache and local_config.cache_enabled: + # TODO: how to get a nice `native_inputs` here? + logger.info( + f"Checking cache for task named {self.name}, cache version {self.metadata.cache_version} " + f"and inputs: {input_literal_map}" + ) + if local_config.cache_overwrite: + outputs_literal_map = None + logger.info("Cache overwrite, task will be executed now") + else: + outputs_literal_map = LocalTaskCache.get(self.name, self.metadata.cache_version, input_literal_map) + # The cache returns None iff the key does not exist in the cache + if outputs_literal_map is None: + logger.info("Cache miss, task will be executed now") + else: + logger.info("Cache hit") + if outputs_literal_map is None: + outputs_literal_map = self.sandbox_execute(ctx, input_literal_map) + # TODO: need `native_inputs` + LocalTaskCache.set(self.name, self.metadata.cache_version, input_literal_map, outputs_literal_map) + logger.info( + f"Cache set for task named {self.name}, cache version {self.metadata.cache_version} " + f"and inputs: {input_literal_map}" + ) + else: + # This code should mirror the call to `sandbox_execute` in the above cache case. + # Code is simpler with duplication and less metaprogramming, but introduces regressions + # if one is changed and not the other. + outputs_literal_map = self.sandbox_execute(ctx, input_literal_map) + + if inspect.iscoroutine(outputs_literal_map): + return outputs_literal_map + + outputs_literals = outputs_literal_map.literals + + # TODO maybe this is the part that should be done for local execution, we pass the outputs to some special + # location, otherwise we dont really need to right? The higher level execute could just handle literalMap + # After running, we again have to wrap the outputs, if any, back into Promise objects + output_names = list(self.interface.outputs.keys()) # type: ignore + if len(output_names) != len(outputs_literals): + # Length check, clean up exception + raise AssertionError(f"Length difference {len(output_names)} {len(outputs_literals)}") + + # Tasks that don't return anything still return a VoidPromise + if len(output_names) == 0: + return VoidPromise(self.name) + + vals = [Promise(var, outputs_literals[var]) for var in output_names] + return create_task_output(vals, self.python_interface) + + def __call__(self, *args: object, **kwargs: object) -> Union[Tuple[Promise], Promise, VoidPromise, Tuple, None]: + return flyte_entity_call_handler(self, *args, **kwargs) # type: ignore + + def compile(self, ctx: FlyteContext, *args, **kwargs): + raise Exception("not implemented") + + def get_container(self, settings: SerializationSettings) -> Optional[_task_model.Container]: + """ + Returns the container definition (if any) that is used to run the task on hosted Flyte. + """ + return None + + def get_k8s_pod(self, settings: SerializationSettings) -> Optional[_task_model.K8sPod]: + """ + Returns the kubernetes pod definition (if any) that is used to run the task on hosted Flyte. + """ + return None + + def get_sql(self, settings: SerializationSettings) -> Optional[_task_model.Sql]: + """ + Returns the Sql definition (if any) that is used to run the task on hosted Flyte. + """ + return None + + def get_custom(self, settings: SerializationSettings) -> Optional[Dict[str, Any]]: + """ + Return additional plugin-specific custom data (if any) as a serializable dictionary. + """ + return None + + def get_config(self, settings: SerializationSettings) -> Optional[Dict[str, str]]: + """ + Returns the task config as a serializable dictionary. This task config consists of metadata about the custom + defined for this task. + """ + return None + + def get_extended_resources(self, settings: SerializationSettings) -> Optional[tasks_pb2.ExtendedResources]: + """ + Returns the extended resources to allocate to the task on hosted Flyte. + """ + return None + + def local_execution_mode(self) -> ExecutionState.Mode: + """ """ + return ExecutionState.Mode.LOCAL_TASK_EXECUTION + + def sandbox_execute( + self, + ctx: FlyteContext, + input_literal_map: _literal_models.LiteralMap, + ) -> _literal_models.LiteralMap: + """ + Call dispatch_execute, in the context of a local sandbox execution. Not invoked during runtime. + """ + es = cast(ExecutionState, ctx.execution_state) + b = cast(ExecutionParameters, es.user_space_params).with_task_sandbox() + ctx = ctx.current_context().with_execution_state(es.with_params(user_space_params=b.build())).build() + return self.dispatch_execute(ctx, input_literal_map) + + @abstractmethod + def dispatch_execute( + self, + ctx: FlyteContext, + input_literal_map: _literal_models.LiteralMap, + ) -> _literal_models.LiteralMap: + """ + This method translates Flyte's Type system based input values and invokes the actual call to the executor + This method is also invoked during runtime. + """ + pass + + @abstractmethod + def pre_execute(self, user_params: ExecutionParameters) -> ExecutionParameters: + """ + This is the method that will be invoked directly before executing the task method and before all the inputs + are converted. One particular case where this is useful is if the context is to be modified for the user process + to get some user space parameters. This also ensures that things like SparkSession are already correctly + setup before the type transformers are called + + This should return either the same context of the mutated context + """ + pass + + @abstractmethod + def execute(self, **kwargs) -> Any: + """ + This method will be invoked to execute the task. + """ + pass + + +T = TypeVar("T") + + +class PythonTask(TrackedInstance, Task, Generic[T]): + """ + Base Class for all Tasks with a Python native ``Interface``. This should be directly used for task types, that do + not have a python function to be executed. Otherwise refer to :py:class:`flytekit.PythonFunctionTask`. + """ + + def __init__( + self, + task_type: str, + name: str, + task_config: Optional[T], + interface: Optional[Interface] = None, + environment: Optional[Dict[str, str]] = None, + disable_deck: Optional[bool] = None, + enable_deck: Optional[bool] = None, + **kwargs, + ): + """ + Args: + task_type (str): defines a unique task-type for every new extension. If a backend plugin is required then + this has to be done in-concert with the backend plugin identifier + name (str): A unique name for the task instantiation. This is unique for every instance of task. + task_config (T): Configuration for the task. This is used to configure the specific plugin that handles this + task + interface (Optional[Interface]): A python native typed interface ``(inputs) -> outputs`` that declares the + signature of the task + environment (Optional[Dict[str, str]]): Any environment variables that should be supplied during the + execution of the task. Supplied as a dictionary of key/value pairs + disable_deck (bool): (deprecated) If true, this task will not output deck html file + enable_deck (bool): If true, this task will output deck html file + """ + super().__init__( + task_type=task_type, + name=name, + interface=transform_interface_to_typed_interface(interface), + **kwargs, + ) + self._python_interface = interface if interface else Interface() + self._environment = environment if environment else {} + self._task_config = task_config + + if disable_deck is not None: + warnings.warn("disable_deck was deprecated in 1.10.0, please use enable_deck instead", FutureWarning) + + # Confirm that disable_deck and enable_deck do not contradict each other + if disable_deck is not None and enable_deck is not None: + raise ValueError("disable_deck and enable_deck cannot both be set at the same time") + + if enable_deck is not None: + self._disable_deck = not enable_deck + elif disable_deck is not None: + self._disable_deck = disable_deck + else: + self._disable_deck = True + if self._python_interface.docstring: + if self.docs is None: + self._docs = Documentation( + short_description=self._python_interface.docstring.short_description, + long_description=Description(value=self._python_interface.docstring.long_description), + ) + else: + if self._python_interface.docstring.short_description: + cast( + Documentation, self._docs + ).short_description = self._python_interface.docstring.short_description + if self._python_interface.docstring.long_description: + cast(Documentation, self._docs).long_description = Description( + value=self._python_interface.docstring.long_description + ) + + # TODO lets call this interface and the other as flyte_interface? + @property + def python_interface(self) -> Interface: + """ + Returns this task's python interface. + """ + return self._python_interface + + @property + def task_config(self) -> Optional[T]: + """ + Returns the user-specified task config which is used for plugin-specific handling of the task. + """ + return self._task_config + + def get_type_for_input_var(self, k: str, v: Any) -> Type[Any]: + """ + Returns the python type for an input variable by name. + """ + return self._python_interface.inputs[k] + + def get_type_for_output_var(self, k: str, v: Any) -> Type[Any]: + """ + Returns the python type for the specified output variable by name. + """ + return self._python_interface.outputs[k] + + def get_input_types(self) -> Dict[str, type]: + """ + Returns the names and python types as a dictionary for the inputs of this task. + """ + return self._python_interface.inputs + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + """ + Used when constructing the node that encapsulates this task as part of a broader workflow definition. + """ + return _workflow_model.NodeMetadata( + name=extract_obj_name(self.name), + timeout=self.metadata.timeout, + retries=self.metadata.retry_strategy, + interruptible=self.metadata.interruptible, + ) + + def compile(self, ctx: FlyteContext, *args, **kwargs) -> Optional[Union[Tuple[Promise], Promise, VoidPromise]]: + """ + Generates a node that encapsulates this task in a workflow definition. + """ + return create_and_link_node(ctx, entity=self, **kwargs) + + @property + def _outputs_interface(self) -> Dict[Any, Variable]: + return self.interface.outputs # type: ignore + + def _literal_map_to_python_input( + self, literal_map: _literal_models.LiteralMap, ctx: FlyteContext + ) -> Dict[str, Any]: + return TypeEngine.literal_map_to_kwargs(ctx, literal_map, self.python_interface.inputs) + + def _output_to_literal_map(self, native_outputs: Dict[int, Any], ctx: FlyteContext): + expected_output_names = list(self._outputs_interface.keys()) + if len(expected_output_names) == 1: + # Here we have to handle the fact that the task could've been declared with a typing.NamedTuple of + # length one. That convention is used for naming outputs - and single-length-NamedTuples are + # particularly troublesome, but elegant handling of them is not a high priority + # Again, we're using the output_tuple_name as a proxy. + if self.python_interface.output_tuple_name and isinstance(native_outputs, tuple): + native_outputs_as_map = {expected_output_names[0]: native_outputs[0]} + else: + native_outputs_as_map = {expected_output_names[0]: native_outputs} + elif len(expected_output_names) == 0: + native_outputs_as_map = {} + else: + native_outputs_as_map = {expected_output_names[i]: native_outputs[i] for i, _ in enumerate(native_outputs)} + + # 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. + 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(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 + + return _literal_models.LiteralMap(literals=literals), native_outputs_as_map + + def _write_decks(self, native_inputs, native_outputs_as_map, ctx, new_user_params): + if self._disable_deck is False: + from flytekit.deck.deck import Deck, _output_deck + + INPUT = "input" + OUTPUT = "output" + + input_deck = Deck(INPUT) + for k, v in native_inputs.items(): + input_deck.append(TypeEngine.to_html(ctx, v, self.get_type_for_input_var(k, v))) + + output_deck = Deck(OUTPUT) + for k, v in native_outputs_as_map.items(): + output_deck.append(TypeEngine.to_html(ctx, v, self.get_type_for_output_var(k, v))) + + if ctx.execution_state and ctx.execution_state.is_local_execution(): + # When we run the workflow remotely, flytekit outputs decks at the end of _dispatch_execute + _output_deck(self.name.split(".")[-1], new_user_params) + + async def _async_execute(self, native_inputs, native_outputs, ctx, exec_ctx, new_user_params): + native_outputs = await native_outputs + native_outputs = self.post_execute(new_user_params, native_outputs) + literals_map, native_outputs_as_map = self._output_to_literal_map(native_outputs, exec_ctx) + self._write_decks(native_inputs, native_outputs_as_map, ctx, new_user_params) + return literals_map + + def dispatch_execute( + self, ctx: FlyteContext, input_literal_map: _literal_models.LiteralMap + ) -> Union[_literal_models.LiteralMap, _dynamic_job.DynamicJobSpec, Coroutine]: + """ + This method translates Flyte's Type system based input values and invokes the actual call to the executor + This method is also invoked during runtime. + + * ``VoidPromise`` is returned in the case when the task itself declares no outputs. + * ``Literal Map`` is returned when the task returns either one more outputs in the declaration. Individual outputs + may be none + * ``DynamicJobSpec`` is returned when a dynamic workflow is executed + """ + # Invoked before the task is executed + new_user_params = self.pre_execute(ctx.user_space_params) + + # Create another execution context with the new user params, but let's keep the same working dir + with FlyteContextManager.with_context( + ctx.with_execution_state( + cast(ExecutionState, ctx.execution_state).with_params(user_space_params=new_user_params) + ) + # type: ignore + ) 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 + try: + native_inputs = self._literal_map_to_python_input(input_literal_map, exec_ctx) + 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}") + with timeit("Execute user level code"): + native_outputs = self.execute(**native_inputs) + + if inspect.iscoroutine(native_outputs): + # If native outputs is a coroutine, then this is an eager workflow. + if exec_ctx.execution_state: + if exec_ctx.execution_state.mode == ExecutionState.Mode.LOCAL_TASK_EXECUTION: + # Just return task outputs as a coroutine if the eager workflow is being executed locally, + # outside of a workflow. This preserves the expectation that the eager workflow is an async + # function. + return native_outputs + elif exec_ctx.execution_state.mode == ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION: + # If executed inside of a workflow being executed locally, then run the coroutine to get the + # actual results. + return asyncio.run( + self._async_execute(native_inputs, native_outputs, ctx, exec_ctx, new_user_params) + ) + + return self._async_execute(native_inputs, native_outputs, ctx, exec_ctx, new_user_params) + + logger.debug("Task executed successfully in user level") + # Lets run the post_execute method. This may result in a IgnoreOutputs Exception, which is + # bubbled up to be handled at the callee layer. + native_outputs = self.post_execute(new_user_params, native_outputs) + + # Short circuit the translation to literal map because what's returned may be a dj spec (or an + # already-constructed LiteralMap if the dynamic task was a no-op), not python native values + # dynamic_execute returns a literal map in local execute so this also gets triggered. + if isinstance(native_outputs, (_literal_models.LiteralMap, _dynamic_job.DynamicJobSpec)): + return native_outputs + + literals_map, native_outputs_as_map = self._output_to_literal_map(native_outputs, exec_ctx) + self._write_decks(native_inputs, native_outputs_as_map, ctx, new_user_params) + # After the execute has been successfully completed + return literals_map + + def pre_execute(self, user_params: Optional[ExecutionParameters]) -> Optional[ExecutionParameters]: # type: ignore + """ + This is the method that will be invoked directly before executing the task method and before all the inputs + are converted. One particular case where this is useful is if the context is to be modified for the user process + to get some user space parameters. This also ensures that things like SparkSession are already correctly + setup before the type transformers are called + + This should return either the same context of the mutated context + """ + return user_params + + @abstractmethod + def execute(self, **kwargs) -> Any: + """ + This method will be invoked to execute the task. + """ + pass + + def post_execute(self, user_params: Optional[ExecutionParameters], rval: Any) -> Any: + """ + Post execute is called after the execution has completed, with the user_params and can be used to clean-up, + or alter the outputs to match the intended tasks outputs. If not overridden, then this function is a No-op + + Args: + rval is returned value from call to execute + user_params: are the modified user params as created during the pre_execute step + """ + return rval + + @property + def environment(self) -> Dict[str, str]: + """ + Any environment variables that supplied during the execution of the task. + """ + return self._environment + + @property + def disable_deck(self) -> bool: + """ + If true, this task will not output deck html file + """ + return self._disable_deck + + +class TaskResolverMixin(object): + """ + Flytekit tasks interact with the Flyte platform very, very broadly in two steps. They need to be uploaded to Admin, + and then they are run by the user upon request (either as a single task execution or as part of a workflow). In any + case, at execution time, for most tasks (that is those that generate a container target) the container image + containing the task needs to be spun up again at which point the container needs to know which task it's supposed + to run and how to rehydrate the task object. + + For example, the serialization of a simple task :: + + # in repo_root/workflows/example.py + @task + def t1(...) -> ...: ... + + might result in a container with arguments like :: + + pyflyte-execute --inputs s3://path/inputs.pb --output-prefix s3://outputs/location \ + --raw-output-data-prefix /tmp/data \ + --resolver flytekit.core.python_auto_container.default_task_resolver \ + -- \ + task-module repo_root.workflows.example task-name t1 + + At serialization time, the container created for the task will start out automatically with the ``pyflyte-execute`` + bit, along with the requisite input/output args and the offloaded data prefix. Appended to that will be two things, + + #. the ``location`` of the task's task resolver, followed by two dashes, followed by + #. the arguments provided by calling the ``loader_args`` function below. + + The ``default_task_resolver`` declared below knows that + + * When ``loader_args`` is called on a task, to look up the module the task is in, and the name of the task (the + key of the task in the module, either the function name, or the variable it was assigned to). + * When ``load_task`` is called, it interprets the first part of the command as the module to call + ``importlib.import_module`` on, and then looks for a key ``t1``. + + This is just the default behavior. Users should feel free to implement their own resolvers. + """ + + @property + @abstractmethod + def location(self) -> str: + pass + + @abstractmethod + def name(self) -> str: + pass + + @abstractmethod + def load_task(self, loader_args: List[str]) -> Task: + """ + Given the set of identifier keys, should return one Python Task or raise an error if not found + """ + pass + + @abstractmethod + def loader_args(self, settings: SerializationSettings, t: Task) -> List[str]: + """ + Return a list of strings that can help identify the parameter Task + """ + pass + + @abstractmethod + def get_all_tasks(self) -> List[Task]: + """ + Future proof method. Just making it easy to access all tasks (Not required today as we auto register them) + """ + pass + + def task_name(self, t: Task) -> Optional[str]: + """ + Overridable function that can optionally return a custom name for a given task + """ + return None diff --git a/flytekit/flytekit/core/checkpointer.py b/flytekit/flytekit/core/checkpointer.py new file mode 100644 index 0000000000..7ac649a487 --- /dev/null +++ b/flytekit/flytekit/core/checkpointer.py @@ -0,0 +1,158 @@ +import io +import tempfile +import typing +from abc import abstractmethod +from pathlib import Path + + +class Checkpoint(object): + """ + Base class for Checkpoint system. Checkpoint system allows reading and writing custom checkpoints from user + scripts + """ + + @abstractmethod + def prev_exists(self) -> bool: + raise NotImplementedError("Use one of the derived classes") + + @abstractmethod + def restore(self, path: typing.Union[Path, str]) -> typing.Optional[Path]: + """ + Given a path, if a previous checkpoint exists, will be downloaded to this path. + If download is successful the downloaded path is returned + + .. note: + + Download will not be performed, if the checkpoint was previously restored. The method will return the + previously downloaded path. + + """ + raise NotImplementedError("Use one of the derived classes") + + @abstractmethod + def save(self, cp: typing.Union[Path, str, io.BufferedReader]): + """ + Args: + cp: Checkpoint file (path, str path or a io.BufferedReader) + + Usage: If you have a io.BufferedReader then the following should work + + .. code-block: python + + with input_file.open(mode="rb") as b: + checkpointer.save(b) + """ + raise NotImplementedError("Use one of the derived classes") + + @abstractmethod + def read(self) -> typing.Optional[bytes]: + """ + This should only be used if there is a singular checkpoint file written. If more than one checkpoint file is + found, this will raise a ValueError + """ + raise NotImplementedError("Use one of the derived classes") + + @abstractmethod + def write(self, b: bytes): + """ + This will overwrite the checkpoint. It can be retrieved using read or restore + """ + raise NotImplementedError("Use one of the derived classes") + + +class SyncCheckpoint(Checkpoint): + """ + This class is NOT THREAD-SAFE! + Sync Checkpoint, will synchronously checkpoint a user given file or folder. + It will also synchronously download / restore previous checkpoints, when restore is invoked. + + TODO: Implement an async checkpoint system + """ + + SRC_LOCAL_FOLDER = "prev_cp" + TMP_DST_PATH = "_dst_cp" + + def __init__(self, checkpoint_dest: str, checkpoint_src: typing.Optional[str] = None): + """ + Args: + checkpoint_src: If a previous checkpoint should exist, this path should be set to the folder that contains the checkpoint information + checkpoint_dest: Location where the new checkpoint should be copied to + """ + self._checkpoint_dest = checkpoint_dest + self._checkpoint_src = checkpoint_src if checkpoint_src and checkpoint_src != "" else None + self._td = tempfile.TemporaryDirectory() + self._prev_download_path: typing.Optional[Path] = None + + def __del__(self): + self._td.cleanup() + + def prev_exists(self) -> bool: + return self._checkpoint_src is not None + + def restore(self, path: typing.Optional[typing.Union[Path, str]] = None) -> typing.Optional[Path]: + # We have to lazy load, until we fix the imports + from flytekit.core.context_manager import FlyteContextManager + + if self._checkpoint_src is None or self._checkpoint_src == "": + return None + + if self._prev_download_path: + return self._prev_download_path + + if path is None: + p = Path(self._td.name) + path = p.joinpath(self.SRC_LOCAL_FOLDER) + path.mkdir(exist_ok=True) + elif isinstance(path, str): + path = Path(path) + + if not path.is_dir(): + raise ValueError("Checkpoints can be restored to a directory only.") + + FlyteContextManager.current_context().file_access.download_directory(self._checkpoint_src, str(path)) + self._prev_download_path = path + return self._prev_download_path + + def save(self, cp: typing.Union[Path, str, io.BufferedReader]): + # We have to lazy load, until we fix the imports + from flytekit.core.context_manager import FlyteContextManager + + fa = FlyteContextManager.current_context().file_access + if isinstance(cp, (Path, str)): + if isinstance(cp, str): + cp = Path(cp) + if cp.is_dir(): + fa.upload_directory(str(cp), self._checkpoint_dest) + else: + fname = cp.stem + cp.suffix + rpath = fa._default_remote.sep.join([str(self._checkpoint_dest), fname]) + fa.upload(str(cp), rpath) + return + + if not isinstance(cp, io.IOBase): + raise ValueError(f"Only a valid path or IOBase type (reader) should be provided, received {type(cp)}") + + p = Path(self._td.name) + dest_cp = p.joinpath(self.TMP_DST_PATH) + with dest_cp.open("wb") as f: + f.write(cp.read()) + + 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]: + p = self.restore() + if p is None: + return None + files = list(p.iterdir()) + if len(files) == 0: + return None + if len(files) > 1: + raise ValueError(f"Expected exactly one checkpoint - found {len(files)}") + f = files[0] + return f.read_bytes() + + def write(self, b: bytes): + p = io.BytesIO(b) + f = typing.cast(io.BufferedReader, p) + self.save(f) diff --git a/flytekit/flytekit/core/class_based_resolver.py b/flytekit/flytekit/core/class_based_resolver.py new file mode 100644 index 0000000000..49970d5623 --- /dev/null +++ b/flytekit/flytekit/core/class_based_resolver.py @@ -0,0 +1,43 @@ +from typing import List + +from flytekit.configuration import SerializationSettings +from flytekit.core.base_task import TaskResolverMixin +from flytekit.core.python_auto_container import PythonAutoContainerTask +from flytekit.core.tracker import TrackedInstance + + +class ClassStorageTaskResolver(TrackedInstance, TaskResolverMixin): + """ + Stores tasks inside a class variable. The class must be inherited from at the point of usage because the task + loading process basically relies on the same sequence of things happening. + """ + + def __init__(self, *args, **kwargs): + self.mapping = [] + super().__init__(*args, **kwargs) + + def name(self) -> str: + return "ClassStorageTaskResolver" + + def get_all_tasks(self) -> List[PythonAutoContainerTask]: # type:ignore + return self.mapping + + def add(self, t: PythonAutoContainerTask): + self.mapping.append(t) + + def load_task(self, loader_args: List[str]) -> PythonAutoContainerTask: + if len(loader_args) != 1: + raise RuntimeError(f"Unable to load task, received ambiguous loader args {loader_args}, expected only one") + + # string should be parseable as an int + idx = int(loader_args[0]) + return self.mapping[idx] + + def loader_args(self, settings: SerializationSettings, t: PythonAutoContainerTask) -> List[str]: # type: ignore + """ + This is responsible for turning an instance of a task into args that the load_task function can reconstitute. + """ + if t not in self.mapping: + raise Exception("no such task") + + return [f"{self.mapping.index(t)}"] diff --git a/flytekit/flytekit/core/condition.py b/flytekit/flytekit/core/condition.py new file mode 100644 index 0000000000..bc7b4df865 --- /dev/null +++ b/flytekit/flytekit/core/condition.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import datetime +import typing +from typing import Optional, Tuple, Union, cast + +from flytekit.core.context_manager import FlyteContextManager +from flytekit.core.node import Node +from flytekit.core.promise import ( + ComparisonExpression, + ComparisonOps, + ConjunctionExpression, + ConjunctionOps, + NodeOutput, + Promise, + VoidPromise, + create_task_output, +) +from flytekit.models.core import condition as _core_cond +from flytekit.models.core import workflow as _core_wf +from flytekit.models.literals import Binding, BindingData, Literal, RetryStrategy +from flytekit.models.types import Error + + +class BranchNode(object): + def __init__(self, name: str, ifelse_block: _core_wf.IfElseBlock): + self._name = name + self._ifelse_block = ifelse_block + + @property + def name(self): + return self._name + + +class ConditionalSection: + """ + ConditionalSection is used to denote a condition within a Workflow. This default conditional section only works + for Compilation mode. It is advised to derive the class and re-implement the `start_branch` and `end_branch` methods + to override the compilation behavior + + .. note:: + + Conditions can only be used within a workflow context. + + Usage: + + .. code-block:: python + + v = conditional("fractions").if_((my_input > 0.1) & (my_input < 1.0)).then(...)... + + """ + + def __init__(self, name: str): + self._name = name + self._cases: typing.List[Case] = [] + self._last_case = False + self._condition = Condition(self) + ctx = FlyteContextManager.current_context() + # A new conditional section has been started, so lets push the context + FlyteContextManager.push_context(ctx.enter_conditional_section().build()) + + @property + def name(self) -> str: + return self._name + + @property + def cases(self) -> typing.List[Case]: + return self._cases + + def start_branch(self, c: Case, last_case: bool = False) -> Case: + """ + At the start of an execution of every branch this method should be called. + :param c: -> the case that represents this branch + :param last_case: -> a boolean that indicates if this is the last branch in the ifelseblock + """ + self._last_case = last_case + self._cases.append(c) + return self._cases[-1] + + def end_branch(self) -> Optional[Union[Condition, Promise, Tuple[Promise], VoidPromise]]: + """ + This should be invoked after every branch has been visited. + In case this is not local workflow execution then, we should check if this is the last case. + If so then return the promise, else return the condition + """ + if self._last_case: + # We have completed the conditional section, lets pop off the branch context + FlyteContextManager.pop_context() + ctx = FlyteContextManager.current_context() + # Question: This is commented out because we don't need it? Nodes created in the conditional + # compilation state are captured in the to_case_block? Always? + # Is this still true of nested conditionals? Is that why propeller compiler is complaining? + # branch_nodes = ctx.compilation_state.nodes + node, promises = to_branch_node(self._name, self) + # Verify branch_nodes == nodes in bn + bindings: typing.List[Binding] = [] + upstream_nodes = set() + for p in promises: + if not p.is_ready: + bindings.append(Binding(var=p.var, binding=BindingData(promise=p.ref))) + upstream_nodes.add(p.ref.node) + + n = Node( + id=f"{ctx.compilation_state.prefix}n{len(ctx.compilation_state.nodes)}", # type: ignore + metadata=_core_wf.NodeMetadata(self._name, timeout=datetime.timedelta(), retries=RetryStrategy(0)), + bindings=sorted(bindings, key=lambda b: b.var), + upstream_nodes=list(upstream_nodes), # type: ignore + flyte_entity=node, + ) + FlyteContextManager.current_context().compilation_state.add_node(n) # type: ignore + return self._compute_outputs(n) + return self._condition + + def if_(self, expr: Union[ComparisonExpression, ConjunctionExpression]) -> Case: + return self._condition._if(expr) + + def compute_output_vars(self) -> typing.Optional[typing.List[str]]: + """ + Computes and returns the minimum set of outputs for this conditional block, based on all the cases that have + been registered + """ + output_vars: typing.List[str] = [] + output_vars_set = set() + for c in self._cases: + if c.output_promise is None and c.err is None: + # One node returns a void output and no error, we will default to None return + return None + if isinstance(c.output_promise, VoidPromise): + return None + if c.output_promise is not None: + var = [] + if isinstance(c.output_promise, tuple): + var = [i.var for i in c.output_promise] + else: + var = [c.output_promise.var] + curr_set = set(var) + if not output_vars: + output_vars = var + output_vars_set = curr_set + else: + output_vars_set = output_vars_set.intersection(curr_set) + new_output_var = [] + for v in output_vars: + if v in output_vars_set: + new_output_var.append(v) + output_vars = new_output_var + + return output_vars + + def _compute_outputs(self, n: Node) -> Optional[Union[Promise, Tuple[Promise], VoidPromise]]: + curr = self.compute_output_vars() + if curr is None or len(curr) == 0: + return VoidPromise(n.id, NodeOutput(node=n, var="placeholder")) + promises = [Promise(var=x, val=NodeOutput(node=n, var=x)) for x in curr] + # TODO: Is there a way to add the Python interface here? Currently, it's an optional arg. + return create_task_output(promises) + + def __repr__(self): + return self._condition.__repr__() + + def __str__(self): + return self.__repr__() + + +class LocalExecutedConditionalSection(ConditionalSection): + def __init__(self, name: str): + self._selected_case: Optional[Case] = None + super().__init__(name=name) + + def start_branch(self, c: Case, last_case: bool = False) -> Case: + """ + At the start of an execution of every branch this method should be called. + :param c: -> the case that represents this branch + :param last_case: -> a boolean that indicates if this is the last branch in the ifelseblock + """ + added_case = super().start_branch(c, last_case) + ctx = FlyteContextManager.current_context() + # This is a short-circuit for the case when the branch was taken + # We already have a candidate case selected + if self._selected_case is None: + if c.expr is None or c.expr.eval() or last_case: + ctx.execution_state.take_branch() # type: ignore + self._selected_case = added_case + return added_case + + def end_branch(self) -> Union[Condition, Promise]: + """ + This should be invoked after every branch has been visited + In case of Local workflow execution, we should first mark the branch as complete, then + Then we first check for if this is the last case, + In case this is the last case, we return the output from the selected case - A case should always + be selected (see start_branch) + If this is not the last case, we should return the condition so that further chaining can be done + """ + ctx = FlyteContextManager.current_context() + # Let us mark the execution state as complete + ctx.execution_state.branch_complete() # type: ignore + if self._last_case and self._selected_case: + # We have completed the conditional section, lets pop off the branch context + FlyteContextManager.pop_context() + if self._selected_case.output_promise is None and self._selected_case.err is None: + raise AssertionError("Bad conditional statements, did not resolve in a promise") + elif self._selected_case.output_promise is not None: + return typing.cast(Promise, self._compute_outputs(self._selected_case.output_promise)) + raise ValueError(self._selected_case.err) + return self._condition + + def _compute_outputs(self, selected_output_promise) -> Optional[Union[Tuple[Promise], Promise, VoidPromise]]: + """ + For the local execution case only returns the least common set of outputs + """ + curr = self.compute_output_vars() + if curr is None: + return VoidPromise(self.name) + if not isinstance(selected_output_promise, tuple): + selected_output_promise = (selected_output_promise,) + promises = [Promise(var=x, val=v.val) for x, v in zip(curr, selected_output_promise)] + return create_task_output(promises) + + +class SkippedConditionalSection(ConditionalSection): + """ + This ConditionalSection is used for nested conditionals, when the branch has been evaluated to false. + This ensures that the branch is not evaluated and thus the local tasks are not executed. + """ + + def __init__(self, name: str): + super().__init__(name=name) + + def end_branch(self) -> Optional[Union[Condition, Tuple[Promise], Promise, VoidPromise]]: + """ + This should be invoked after every branch has been visited + """ + if self._last_case: + FlyteContextManager.pop_context() + curr = self.compute_output_vars() + if curr is None: + return VoidPromise(self.name) + promises = [Promise(var=x, val=None) for x in curr] + return create_task_output(promises) + return self._condition + + +class Case(object): + def __init__( + self, + cs: ConditionalSection, + expr: Optional[Union[ComparisonExpression, ConjunctionExpression]], + stmt: str = "elif", + ): + self._cs = cs + if expr is not None: + if isinstance(expr, bool): + raise AssertionError( + "Logical (and/or/is/not) operations are not supported. " + "Expressions Comparison (<,<=,>,>=,==,!=) or Conjunction (&/|) are supported." + f"Received an evaluated expression with val {expr} in {cs.name}.{stmt}" + ) + if isinstance(expr, Promise): + raise AssertionError( + "Flytekit does not support unary expressions of the form `if_(x) - where x is an" + " input value or output of a previous node." + f" Received var {expr} in condition {cs.name}.{stmt}" + ) + if not (isinstance(expr, ConjunctionExpression) or isinstance(expr, ComparisonExpression)): + raise AssertionError( + "Flytekit only supports Comparison (<,<=,>,>=,==,!=) or Conjunction (&/|) " + f"expressions, Received var {expr} in condition {cs.name}.{stmt}" + ) + self._expr = expr + self._output_promise: Optional[Union[Tuple[Promise], Promise]] = None + self._err: Optional[str] = None + self._stmt = stmt + self._output_node = None + + @property + def output_node(self) -> Optional[Node]: + # This is supposed to hold a pointer to the node that created this case. + # It is set in the then() call. but the value will not be set if it's a VoidPromise or None was returned. + return self._output_node + + @property + def expr(self) -> Optional[Union[ComparisonExpression, ConjunctionExpression]]: + return self._expr + + @property + def output_promise(self) -> Optional[Union[Tuple[Promise], Promise]]: + return self._output_promise + + @property + def err(self) -> Optional[str]: + return self._err + + # TODO this is complicated. We do not want this to run + def then( + self, p: Union[Promise, Tuple[Promise]] + ) -> Optional[Union[Condition, Promise, Tuple[Promise], VoidPromise]]: + self._output_promise = p + if isinstance(p, Promise): + if not p.is_ready: + self._output_node = p.ref.node # type: ignore + elif isinstance(p, VoidPromise): + if p.ref is not None: + self._output_node = p.ref.node + elif hasattr(p, "_fields"): + # This condition detects the NamedTuple case and iterates through the fields to find one that has a node + # which should be the first one. + for f in p._fields: # type: ignore + prom = getattr(p, f) + if not prom.is_ready: + self._output_node = prom.ref.node + break + + # We can always mark branch as completed + return self._cs.end_branch() + + def fail(self, err: str) -> Promise: + self._err = err + return cast(Promise, self._cs.end_branch()) + + def __repr__(self): + return f"{self._stmt}({self.expr.__repr__()})" + + def __str__(self): + return self.__repr__() + + +class Condition(object): + def __init__(self, cs: ConditionalSection): + self._cs = cs + + def _if(self, expr: Union[ComparisonExpression, ConjunctionExpression]) -> Case: + if expr is None: + raise AssertionError(f"Required an expression received None for condition:{self._cs.name}.if_(...)") + return self._cs.start_branch(Case(cs=self._cs, expr=expr, stmt="if_")) + + def elif_(self, expr: Union[ComparisonExpression, ConjunctionExpression]) -> Case: + if expr is None: + raise AssertionError(f"Required an expression received None for condition:{self._cs.name}.elif(...)") + return self._cs.start_branch(Case(cs=self._cs, expr=expr, stmt="elif_")) + + def else_(self) -> Case: + return self._cs.start_branch(Case(cs=self._cs, expr=None, stmt="else_"), last_case=True) + + def __repr__(self): + return f"condition({self._cs.name}) " + "".join([x.__repr__() for x in self._cs.cases]) + + def __str__(self): + return self.__repr__() + + +_logical_ops = { + ConjunctionOps.AND: _core_cond.ConjunctionExpression.LogicalOperator.AND, + ConjunctionOps.OR: _core_cond.ConjunctionExpression.LogicalOperator.OR, +} +_comparators = { + ComparisonOps.EQ: _core_cond.ComparisonExpression.Operator.EQ, + ComparisonOps.NE: _core_cond.ComparisonExpression.Operator.NEQ, + ComparisonOps.GT: _core_cond.ComparisonExpression.Operator.GT, + ComparisonOps.GE: _core_cond.ComparisonExpression.Operator.GTE, + ComparisonOps.LT: _core_cond.ComparisonExpression.Operator.LT, + ComparisonOps.LE: _core_cond.ComparisonExpression.Operator.LTE, +} + + +def create_branch_node_promise_var(node_id: str, var: str) -> str: + """ + Generates a globally (wf-level) unique id for a variable. + + When building bindings for the branch node, the inputs to the conditions (e.g. (x==5)) need to have variable names + (e.g. x). Because it's currently infeasible to get the name (e.g. x), we resolve to using the referenced node's + output name (e.g. o0, my_out,... etc.). In order to avoid naming collisions (in cases when, for example, the + conditions reference two outputs of two different nodes named the same), we build a variable name composed of the + referenced node name + '.' + the referenced output name. Ideally we use something like + (https://github.com/pwwang/python-varname) to retrieve the assigned variable name (e.g. x). However, because of + https://github.com/pwwang/python-varname/issues/28, this is not currently supported for all AST nodes types. + + :param str node_id: the original node_id that produced the variable. + :param str var: the output variable name from the original node. + :return: The generated unique id of the variable. + """ + return f"{node_id}.{var}" + + +def merge_promises(*args: Optional[Promise]) -> typing.List[Promise]: + node_vars: typing.Set[typing.Tuple[str, str]] = set() + merged_promises: typing.List[Promise] = [] + for p in args: + if p is not None and p.ref: + node_var = (p.ref.node_id, p.ref.var) + if node_var not in node_vars: + new_p = p.with_var(create_branch_node_promise_var(p.ref.node_id, p.ref.var)) + merged_promises.append(new_p) + node_vars.add(node_var) + return merged_promises + + +def transform_to_conj_expr( + expr: ConjunctionExpression, +) -> Tuple[_core_cond.ConjunctionExpression, typing.List[Promise]]: + left, left_promises = transform_to_boolexpr(expr.lhs) + right, right_promises = transform_to_boolexpr(expr.rhs) + return ( + _core_cond.ConjunctionExpression( + left_expression=left, + right_expression=right, + operator=_logical_ops[expr.op], + ), + merge_promises(*left_promises, *right_promises), + ) + + +def transform_to_operand(v: Union[Promise, Literal]) -> Tuple[_core_cond.Operand, Optional[Promise]]: + if isinstance(v, Promise): + return _core_cond.Operand(var=create_branch_node_promise_var(v.ref.node_id, v.var)), v + if v.scalar.none_type: + return _core_cond.Operand(scalar=v.scalar), None + return _core_cond.Operand(primitive=v.scalar.primitive), None + + +def transform_to_comp_expr(expr: ComparisonExpression) -> Tuple[_core_cond.ComparisonExpression, typing.List[Promise]]: + o_lhs, b_lhs = transform_to_operand(expr.lhs) + o_rhs, b_rhs = transform_to_operand(expr.rhs) + return ( + _core_cond.ComparisonExpression(left_value=o_lhs, right_value=o_rhs, operator=_comparators[expr.op]), + merge_promises(b_lhs, b_rhs), + ) + + +def transform_to_boolexpr( + expr: Union[ComparisonExpression, ConjunctionExpression] +) -> Tuple[_core_cond.BooleanExpression, typing.List[Promise]]: + if isinstance(expr, ConjunctionExpression): + cexpr, promises = transform_to_conj_expr(expr) + return _core_cond.BooleanExpression(conjunction=cexpr), promises + cexpr, promises = transform_to_comp_expr(expr) + return _core_cond.BooleanExpression(comparison=cexpr), promises + + +def to_case_block(c: Case) -> Tuple[Union[_core_wf.IfBlock], typing.List[Promise]]: + expr, promises = transform_to_boolexpr(cast(Union[ComparisonExpression, ConjunctionExpression], c.expr)) + if c.output_promise is not None: + n = c.output_node + return _core_wf.IfBlock(condition=expr, then_node=n), promises + + +def to_ifelse_block(node_id: str, cs: ConditionalSection) -> Tuple[_core_wf.IfElseBlock, typing.List[Binding]]: + if len(cs.cases) == 0: + raise AssertionError("Illegal Condition block, with no if-else cases") + if len(cs.cases) < 2: + raise AssertionError("At least an if/else is required. Dangling If is not allowed") + all_promises: typing.List[Promise] = [] + first_case, promises = to_case_block(cs.cases[0]) + all_promises.extend(promises) + other_cases: Optional[typing.List[_core_wf.IfBlock]] = None + if len(cs.cases) > 2: + other_cases = [] + for c in cs.cases[1:-1]: + case, promises = to_case_block(c) + all_promises.extend(promises) + other_cases.append(case) + last_case = cs.cases[-1] + node = None + err = None + if last_case.output_promise is not None: + node = last_case.output_node + else: + err = Error(failed_node_id=node_id, message=last_case.err if last_case.err else "Condition failed") + return ( + _core_wf.IfElseBlock(case=first_case, other=other_cases, else_node=node, error=err), + merge_promises(*all_promises), + ) + + +def to_branch_node(name: str, cs: ConditionalSection) -> Tuple[BranchNode, typing.List[Promise]]: + blocks, promises = to_ifelse_block(name, cs) + return BranchNode(name=name, ifelse_block=blocks), promises + + +def conditional(name: str) -> ConditionalSection: + """ + Use a conditional section to control the flow of a workflow. Conditional sections can only be used inside a workflow + context. Outside of a workflow they will raise an Assertion. + + The ``conditional`` method returns a new conditional section, that allows to create a - ternary operator like + if-else clauses. The reason why it is called ternary-like is because, it returns the output of the branch result. + So in-effect it is a functional style condition. + + Example of a condition usage. Note the nesting and the assignment to a LHS variable + + .. code-block:: python + + v = ( + conditional("fractions") + .if_((my_input > 0.1) & (my_input < 1.0)) + .then( + conditional("inner_fractions") + .if_(my_input < 0.5) + .then(double(n=my_input)) + .elif_((my_input > 0.5) & (my_input < 0.7)) + .then(square(n=my_input)) + .else_() + .fail("Only <0.7 allowed") + ) + .elif_((my_input > 1.0) & (my_input < 10.0)) + .then(square(n=my_input)) + .else_() + .then(double(n=my_input)) + ) + """ + ctx = FlyteContextManager.current_context() + + if ctx.compilation_state: + return ConditionalSection(name) + elif ctx.execution_state: + if ctx.execution_state.is_local_execution(): + # In case of Local workflow execution, we will actually evaluate the expression and based on the result + # make the branch to be active using `take_branch` method + from flytekit.core.context_manager import BranchEvalMode + + if ctx.execution_state.branch_eval_mode == BranchEvalMode.BRANCH_SKIPPED: + return SkippedConditionalSection(name) + return LocalExecutedConditionalSection(name) + raise AssertionError("Branches can only be invoked within a workflow context!") diff --git a/flytekit/flytekit/core/constants.py b/flytekit/flytekit/core/constants.py new file mode 100644 index 0000000000..8b85479fcc --- /dev/null +++ b/flytekit/flytekit/core/constants.py @@ -0,0 +1,11 @@ +INPUT_FILE_NAME = "inputs.pb" +OUTPUT_FILE_NAME = "outputs.pb" +FUTURES_FILE_NAME = "futures.pb" +ERROR_FILE_NAME = "error.pb" +REQUIREMENTS_FILE_NAME = "requirements.txt" + +CONTAINER_ARRAY_TASK = "container_array" +GLOBAL_INPUT_NODE_ID = "" + +START_NODE_ID = "start-node" +END_NODE_ID = "end-node" diff --git a/flytekit/flytekit/core/container_task.py b/flytekit/flytekit/core/container_task.py new file mode 100644 index 0000000000..1b078f83a7 --- /dev/null +++ b/flytekit/flytekit/core/container_task.py @@ -0,0 +1,152 @@ +import typing +from enum import Enum +from typing import Any, Dict, List, Optional, OrderedDict, Type + +from flytekit.configuration import SerializationSettings +from flytekit.core.base_task import PythonTask, TaskMetadata +from flytekit.core.context_manager import FlyteContext +from flytekit.core.interface import Interface +from flytekit.core.pod_template import PodTemplate +from flytekit.core.python_auto_container import get_registerable_container_image +from flytekit.core.resources import Resources, ResourceSpec +from flytekit.core.utils import _get_container_definition, _serialize_pod_spec +from flytekit.image_spec.image_spec import ImageSpec +from flytekit.models import task as _task_model +from flytekit.models.security import Secret, SecurityContext + +_PRIMARY_CONTAINER_NAME_FIELD = "primary_container_name" + + +class ContainerTask(PythonTask): + """ + This is an intermediate class that represents Flyte Tasks that run a container at execution time. This is the vast + majority of tasks - the typical ``@task`` decorated tasks for instance all run a container. An example of + something that doesn't run a container would be something like the Athena SQL task. + """ + + class MetadataFormat(Enum): + JSON = _task_model.DataLoadingConfig.LITERALMAP_FORMAT_JSON + YAML = _task_model.DataLoadingConfig.LITERALMAP_FORMAT_YAML + PROTO = _task_model.DataLoadingConfig.LITERALMAP_FORMAT_PROTO + + class IOStrategy(Enum): + DOWNLOAD_EAGER = _task_model.IOStrategy.DOWNLOAD_MODE_EAGER + DOWNLOAD_STREAM = _task_model.IOStrategy.DOWNLOAD_MODE_STREAM + DO_NOT_DOWNLOAD = _task_model.IOStrategy.DOWNLOAD_MODE_NO_DOWNLOAD + UPLOAD_EAGER = _task_model.IOStrategy.UPLOAD_MODE_EAGER + UPLOAD_ON_EXIT = _task_model.IOStrategy.UPLOAD_MODE_ON_EXIT + DO_NOT_UPLOAD = _task_model.IOStrategy.UPLOAD_MODE_NO_UPLOAD + + def __init__( + self, + name: str, + image: typing.Union[str, ImageSpec], + command: List[str], + inputs: Optional[OrderedDict[str, Type]] = None, + metadata: Optional[TaskMetadata] = None, + arguments: Optional[List[str]] = None, + outputs: Optional[Dict[str, Type]] = None, + requests: Optional[Resources] = None, + limits: Optional[Resources] = None, + input_data_dir: Optional[str] = None, + output_data_dir: Optional[str] = None, + metadata_format: MetadataFormat = MetadataFormat.JSON, + io_strategy: Optional[IOStrategy] = None, + secret_requests: Optional[List[Secret]] = None, + pod_template: Optional["PodTemplate"] = None, + pod_template_name: Optional[str] = None, + **kwargs, + ): + sec_ctx = None + if secret_requests: + for s in secret_requests: + 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, + interface=Interface(inputs, outputs), + metadata=metadata, + task_config=None, + security_ctx=sec_ctx, + **kwargs, + ) + self._image = image + self._cmd = command + self._args = arguments + self._input_data_dir = input_data_dir + self._output_data_dir = output_data_dir + self._md_format = metadata_format + self._io_strategy = io_strategy + 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: + return self._resources + + def local_execute(self, ctx: FlyteContext, **kwargs) -> Any: + raise RuntimeError("ContainerTask is not supported in local executions.") + + 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 + if isinstance(self._image, ImageSpec): + if settings.fast_serialization_settings is None or not settings.fast_serialization_settings.enabled: + self._image.source_root = settings.source_root + return _get_container_definition( + image=get_registerable_container_image(self._image, settings.image_config), + command=self._cmd, + args=self._args, + data_loading_config=self._get_data_loading_config(), + environment=env, + ephemeral_storage_request=self.resources.requests.ephemeral_storage, + cpu_request=self.resources.requests.cpu, + gpu_request=self.resources.requests.gpu, + memory_request=self.resources.requests.mem, + ephemeral_storage_limit=self.resources.limits.ephemeral_storage, + cpu_limit=self.resources.limits.cpu, + 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), 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/flytekit/core/context_manager.py b/flytekit/flytekit/core/context_manager.py new file mode 100644 index 0000000000..0b21ab24d9 --- /dev/null +++ b/flytekit/flytekit/core/context_manager.py @@ -0,0 +1,900 @@ +""" + +.. autoclass:: flytekit.core.context_manager::ExecutionState.Mode + :noindex: +.. autoclass:: flytekit.core.context_manager::ExecutionState.Mode.TASK_EXECUTION + :noindex: +.. autoclass:: flytekit.core.context_manager::ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION + :noindex: +.. autoclass:: flytekit.core.context_manager::ExecutionState.Mode.LOCAL_TASK_EXECUTION + :noindex: + +""" + +from __future__ import annotations + +import datetime as _datetime +import logging as _logging +import os +import pathlib +import tempfile +import traceback +import typing +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Generator, List, Optional, Union + +from flytekit.configuration import Config, SecretsConfig, SerializationSettings +from flytekit.core import mock_stats, utils +from flytekit.core.checkpointer import Checkpoint, SyncCheckpoint +from flytekit.core.data_persistence import FileAccessProvider, default_local_file_access_provider +from flytekit.core.node import Node +from flytekit.interfaces.cli_identifiers import WorkflowExecutionIdentifier +from flytekit.interfaces.stats import taggable +from flytekit.loggers import logger, user_space_logger +from flytekit.models.core import identifier as _identifier + +if typing.TYPE_CHECKING: + 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 + +# Enables static type checking https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING + +flyte_context_Var: ContextVar[typing.List[FlyteContext]] = ContextVar("", default=[]) + +if typing.TYPE_CHECKING: + from flytekit.core.base_task import Task, TaskResolverMixin + + +# Identifier fields use placeholders for registration-time substitution. +# Additional fields, such as auth and the raw output data prefix have more complex structures +# and can be optional so they are not serialized with placeholders. + +# During out of container serialize the absolute path of the flytekit virtualenv at serialization time won't match the +# in-container value at execution time. The following default value is used to provide the in-container virtualenv path +# but can be optionally overridden at serialization time based on the installation of your flytekit virtualenv. + + +class ExecutionParameters(object): + """ + This is a run-time user-centric context object that is accessible to every @task method. It can be accessed using + + .. code-block:: python + + flytekit.current_context() + + This object provides the following + * a statsd handler + * a logging handler + * the execution ID as an :py:class:`flytekit.models.core.identifier.WorkflowExecutionIdentifier` object + * a working directory for the user to write arbitrary files to + + Please do not confuse this object with the :py:class:`flytekit.FlyteContext` object. + """ + + @dataclass(init=False) + class Builder(object): + stats: taggable.TaggableStats + attrs: typing.Dict[str, typing.Any] + decks: List[Deck] + raw_output_prefix: Optional[str] = None + execution_id: typing.Optional[_identifier.WorkflowExecutionIdentifier] = None + working_dir: typing.Optional[str] = None + checkpoint: typing.Optional[Checkpoint] = None + execution_date: typing.Optional[datetime] = None + logging: Optional[_logging.Logger] = None + task_id: typing.Optional[_identifier.Identifier] = None + + def __init__(self, current: typing.Optional[ExecutionParameters] = None): + self.stats = current.stats if current else None + self.execution_date = current.execution_date if current else None + self.working_dir = current.working_directory if current else None + self.execution_id = current.execution_id if current else None + self.logging = current.logging if current else None + self.checkpoint = current._checkpoint if current else None + self.decks = current._decks if current else [] + self.attrs = current._attrs if current else {} + self.raw_output_prefix = current.raw_output_prefix if current else None + self.task_id = current.task_id if current else None + + def add_attr(self, key: str, v: typing.Any) -> ExecutionParameters.Builder: + self.attrs[key] = v + return self + + def build(self) -> ExecutionParameters: + if self.working_dir and not isinstance(self.working_dir, utils.AutoDeletingTempDir): + pathlib.Path(typing.cast(str, self.working_dir)).mkdir(parents=True, exist_ok=True) + return ExecutionParameters( + execution_date=self.execution_date, + stats=self.stats, + tmp_dir=self.working_dir, + execution_id=self.execution_id, + logging=self.logging, + checkpoint=self.checkpoint, + decks=self.decks, + raw_output_prefix=self.raw_output_prefix, + task_id=self.task_id, + **self.attrs, + ) + + @staticmethod + def new_builder(current: Optional[ExecutionParameters] = None) -> Builder: + return ExecutionParameters.Builder(current=current) + + def with_task_sandbox(self) -> Builder: + prefix = self.working_directory + if isinstance(self.working_directory, utils.AutoDeletingTempDir): + prefix = self.working_directory.name + task_sandbox_dir = tempfile.mkdtemp(prefix=prefix) # type: ignore + p = pathlib.Path(task_sandbox_dir) + cp_dir = p.joinpath("__cp") + cp_dir.mkdir(exist_ok=True) + cp = SyncCheckpoint(checkpoint_dest=str(cp_dir)) + b = self.new_builder(self) + b.checkpoint = cp + b.working_dir = task_sandbox_dir + return b + + def builder(self) -> Builder: + return ExecutionParameters.Builder(current=self) + + def __init__( + self, + execution_date, + tmp_dir, + stats, + execution_id: typing.Optional[_identifier.WorkflowExecutionIdentifier], + logging, + raw_output_prefix, + output_metadata_prefix=None, + checkpoint=None, + decks=None, + task_id: typing.Optional[_identifier.Identifier] = None, + **kwargs, + ): + """ + Args: + execution_date: Date when the execution is running + tmp_dir: temporary directory for the execution + stats: handle to emit stats + execution_id: Identifier for the execution + logging: handle to logging + checkpoint: Checkpoint Handle to the configured checkpoint system + """ + if decks is None: + decks = [] + self._stats = stats + self._execution_date = execution_date + self._working_directory = tmp_dir + self._execution_id = execution_id + self._logging = logging + self._raw_output_prefix = raw_output_prefix + self._output_metadata_prefix = output_metadata_prefix + # AutoDeletingTempDir's should be used with a with block, which creates upon entry + self._attrs = kwargs + # It is safe to recreate the Secrets Manager + self._secrets_manager = SecretsManager() + self._checkpoint = checkpoint + self._decks = decks + self._task_id = task_id + + @property + def stats(self) -> taggable.TaggableStats: + """ + A handle to a special statsd object that provides usefully tagged stats. + TODO: Usage examples and better comments + """ + return self._stats + + @property + def logging(self) -> _logging.Logger: + """ + A handle to a useful logging object. + TODO: Usage examples + """ + return self._logging + + @property + def raw_output_prefix(self) -> str: + return self._raw_output_prefix + + @property + def output_metadata_prefix(self) -> str: + return self._output_metadata_prefix + + @property + def working_directory(self) -> str: + """ + A handle to a special working directory for easily producing temporary files. + TODO: Usage examples + """ + return self._working_directory + + @property + def execution_date(self) -> datetime: + """ + This is a datetime representing the time at which a workflow was started. This is consistent across all tasks + executed in a workflow or sub-workflow. + + .. note:: + + Do NOT use this execution_date to drive any production logic. It might be useful as a tag for data to help + in debugging. + """ + return self._execution_date + + @property + def execution_id(self) -> _identifier.WorkflowExecutionIdentifier: + """ + This is the identifier of the workflow execution within the underlying engine. It will be consistent across all + task executions in a workflow or sub-workflow execution. + + .. note:: + + Do NOT use this execution_id to drive any production logic. This execution ID should only be used as a tag + on output data to link back to the workflow run that created it. + """ + return self._execution_id + + @property + def task_id(self) -> typing.Optional[_identifier.Identifier]: + """ + At production run-time, this will be generated by reading environment variables that are set + by the backend. + """ + return self._task_id + + @property + def secrets(self) -> SecretsManager: + return self._secrets_manager + + @property + def checkpoint(self) -> Checkpoint: + if self._checkpoint is None: + raise NotImplementedError("Checkpointing is not available, please check the version of the platform.") + return self._checkpoint + + @property + def decks(self) -> typing.List: + """ + A list of decks of the tasks, and it will be rendered to a html at the end of the task execution. + """ + return self._decks + + @property + def default_deck(self) -> 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 + """ + attr_name = attr_name.upper() + if self._attrs and attr_name in self._attrs: + return self._attrs[attr_name] + raise AssertionError(f"{attr_name} not available as a parameter in Flyte context - are you in right task-type?") + + def has_attr(self, attr_name: str) -> bool: + attr_name = attr_name.upper() + if self._attrs and attr_name in self._attrs: + return True + return False + + def get(self, key: str) -> typing.Any: + """ + Returns task specific context if present else raise an error. The returned context will match the key + """ + return self.__getattr__(attr_name=key) # type: ignore + + +class SecretsManager(object): + """ + This provides a secrets resolution logic at runtime. + The resolution order is + - Try env var first. The env var should have the configuration.SECRETS_ENV_PREFIX. The env var will be all upper + cased + - If not then try the file where the name matches lower case + ``configuration.SECRETS_DEFAULT_DIR//configuration.SECRETS_FILE_PREFIX`` + + All configuration values can always be overridden by injecting an environment variable + """ + + class _GroupSecrets(object): + """ + This is a dummy class whose sole purpose is to support "attribute" style lookup for secrets + """ + + def __init__(self, group: str, sm: typing.Any): + self._group = group + self._sm = sm + + def __getattr__(self, item: str) -> str: + """ + Returns the secret that matches "group"."key" + the key, here is the item + """ + return self._sm.get(self._group, item) + + def __init__(self, secrets_cfg: typing.Optional[SecretsConfig] = None): + if secrets_cfg is None: + secrets_cfg = SecretsConfig.auto() + self._base_dir = secrets_cfg.default_dir.strip() + self._file_prefix = secrets_cfg.file_prefix.strip() + self._env_prefix = secrets_cfg.env_prefix.strip() + + def __getattr__(self, item: str) -> _GroupSecrets: + """ + returns a new _GroupSecrets objects, that allows all keys within this group to be looked up like attributes + """ + return self._GroupSecrets(item, self) + + def get( + self, + group: Optional[str] = None, + key: Optional[str] = None, + group_version: Optional[str] = None, + encode_mode: str = "r", + ) -> str: + """ + Retrieves a secret using the resolution order -> Env followed by file. If not found raises a ValueError + param encode_mode, defines the mode to open files, it can either be "r" to read file, or "rb" to read binary file + """ + 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.strip() + if os.path.exists(fpath): + with open(fpath, encode_mode) as f: + return f.read().strip() + raise ValueError( + f"Please make sure to add secret_requests=[Secret(group={group}, key={key})] in @task. 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: Optional[str] = None, 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) + 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: Optional[str] = None, 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) + 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: Optional[str]): + from flytekit.configuration.plugin import get_plugin + + if get_plugin().secret_requires_group() and (group is None or group == ""): + raise ValueError("secrets group is a mandatory field.") + + +@dataclass(frozen=True) +class CompilationState(object): + """ + Compilation state is used during the compilation of a workflow or task. It stores the nodes that were + created when walking through the workflow graph. + + Attributes: + prefix (str): This is because we may one day want to be able to have subworkflows inside other workflows. If + users choose to not specify their node names, then we can end up with multiple "n0"s. This prefix allows + us to give those nested nodes a distinct name, as well as properly identify them in the workflow. + mode (int): refer to :py:class:`flytekit.extend.ExecutionState.Mode` + task_resolver (Optional[TaskResolverMixin]): Please see :py:class:`flytekit.extend.TaskResolverMixin` + nodes (Optional[List]): Stores currently compiled nodes so far. + """ + + prefix: str + mode: int = 1 + task_resolver: Optional[TaskResolverMixin] = None + nodes: List = field(default_factory=list) + + def add_node(self, n: Node): + self.nodes.append(n) + + def with_params( + self, + prefix: str, + mode: Optional[int] = None, + resolver: Optional[TaskResolverMixin] = None, + nodes: Optional[List] = None, + ) -> CompilationState: + """ + Create a new CompilationState where the mode and task resolver are defaulted to the current object, but they + and all other args are taken if explicitly provided as an argument. + + Usage: + s.with_params("p", nodes=[]) + """ + return CompilationState( + prefix=prefix if prefix else "", + mode=mode if mode else self.mode, + task_resolver=resolver if resolver else self.task_resolver, + nodes=nodes if nodes else [], + ) + + +class BranchEvalMode(Enum): + """ + This is a 3-way class, with the None value meaning that we are not within a conditional context. The other two + values are + * Active - This means that the next ``then`` should run + * Skipped - The next ``then`` should not run + """ + + BRANCH_ACTIVE = "branch active" + BRANCH_SKIPPED = "branch skipped" + + +@dataclass(init=False) +class ExecutionState(object): + """ + This is the context that is active when executing a task or a local workflow. This carries the necessary state to + execute. + Some required things during execution deal with temporary directories, ExecutionParameters that are passed to the + user etc. + + Attributes: + mode (ExecutionState.Mode): Defines the context in which the task is executed (local, hosted, etc). + working_dir (os.PathLike): Specifies the remote, external directory where inputs, outputs and other protobufs + are uploaded + engine_dir (os.PathLike): + branch_eval_mode Optional[BranchEvalMode]: Used to determine whether a branch node should execute. + user_space_params Optional[ExecutionParameters]: Provides run-time, user-centric context such as a statsd + handler, a logging handler, the current execution id and a working directory. + """ + + class Mode(Enum): + """ + Defines the possible execution modes, which in turn affects execution behavior. + """ + + # This is the mode that is used when a task execution mimics the actual runtime environment. + # NOTE: This is important to understand the difference between TASK_EXECUTION and LOCAL_TASK_EXECUTION + # LOCAL_TASK_EXECUTION, is the mode that is run purely locally and in some cases the difference between local + # and runtime environment may be different. For example for Dynamic tasks local_task_execution will just run it + # as a regular function, while task_execution will extract a runtime spec + TASK_EXECUTION = 1 + + # This represents when flytekit is locally running a workflow. The behavior of tasks differs in this case + # because instead of running a task's user defined function directly, it'll need to wrap the return values in + # NodeOutput + LOCAL_WORKFLOW_EXECUTION = 2 + + # This is the mode that is used to indicate a purely local task execution - i.e. running without a container + # or propeller. + LOCAL_TASK_EXECUTION = 3 + + # This is the mode that is used to indicate a dynamic task + DYNAMIC_TASK_EXECUTION = 4 + + mode: Optional[ExecutionState.Mode] + working_dir: Union[os.PathLike, str] + engine_dir: Optional[Union[os.PathLike, str]] + branch_eval_mode: Optional[BranchEvalMode] + user_space_params: Optional[ExecutionParameters] + + def __init__( + self, + working_dir: Union[os.PathLike, str], + mode: Optional[ExecutionState.Mode] = None, + engine_dir: Optional[Union[os.PathLike, str]] = None, + branch_eval_mode: Optional[BranchEvalMode] = None, + user_space_params: Optional[ExecutionParameters] = None, + ): + if not working_dir: + raise ValueError("Working directory is needed") + self.working_dir = working_dir + self.mode = mode + self.engine_dir = engine_dir if engine_dir else os.path.join(self.working_dir, "engine_dir") + pathlib.Path(self.engine_dir).mkdir(parents=True, exist_ok=True) + self.branch_eval_mode = branch_eval_mode + self.user_space_params = user_space_params + + def take_branch(self): + """ + Indicates that we are within an if-else block and the current branch has evaluated to true. + Useful only in local execution mode + """ + object.__setattr__(self, "branch_eval_mode", BranchEvalMode.BRANCH_ACTIVE) + + def branch_complete(self): + """ + Indicates that we are within a conditional / ifelse block and the active branch is not done. + Default to SKIPPED + """ + object.__setattr__(self, "branch_eval_mode", BranchEvalMode.BRANCH_SKIPPED) + + def with_params( + self, + working_dir: Optional[os.PathLike] = None, + mode: Optional[Mode] = None, + engine_dir: Optional[os.PathLike] = None, + branch_eval_mode: Optional[BranchEvalMode] = None, + user_space_params: Optional[ExecutionParameters] = None, + ) -> ExecutionState: + """ + Produces a copy of the current execution state and overrides the copy's parameters with passed parameter values. + """ + return ExecutionState( + working_dir=working_dir if working_dir else self.working_dir, + mode=mode if mode else self.mode, + engine_dir=engine_dir if engine_dir else self.engine_dir, + branch_eval_mode=branch_eval_mode if branch_eval_mode else self.branch_eval_mode, + user_space_params=user_space_params if user_space_params else self.user_space_params, + ) + + def is_local_execution(self): + return ( + self.mode == ExecutionState.Mode.LOCAL_TASK_EXECUTION + or self.mode == ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION + ) + + +@dataclass(frozen=True) +class FlyteContext(object): + """ + This is an internal-facing context object, that most users will not have to deal with. It's essentially a globally + available grab bag of settings and objects that allows flytekit to do things like convert complex types, run and + compile workflows, serialize Flyte entities, etc. + + Even though this object as a ``current_context`` function on it, it should not be called directly. Please use the + :py:class:`flytekit.FlyteContextManager` object instead. + + Please do not confuse this object with the :py:class:`flytekit.ExecutionParameters` object. + """ + + file_access: FileAccessProvider + level: int = 0 + flyte_client: Optional["friendly_client.SynchronousFlyteClient"] = None + compilation_state: Optional[CompilationState] = None + execution_state: Optional[ExecutionState] = None + serialization_settings: Optional[SerializationSettings] = None + in_a_condition: bool = False + origin_stackframe: Optional[traceback.FrameSummary] = None + + @property + def user_space_params(self) -> Optional[ExecutionParameters]: + if self.execution_state: + return self.execution_state.user_space_params + return None + + def set_stackframe(self, s: traceback.FrameSummary): + object.__setattr__(self, "origin_stackframe", s) + + def get_origin_stackframe_repr(self) -> str: + if self.origin_stackframe: + f = self.origin_stackframe + return f"StackOrigin({f.name}, {f.lineno}, {f.filename})" + return "" + + def new_builder(self) -> Builder: + return FlyteContext.Builder( + level=self.level, + file_access=self.file_access, + flyte_client=self.flyte_client, + serialization_settings=self.serialization_settings, + compilation_state=self.compilation_state, + execution_state=self.execution_state, + in_a_condition=self.in_a_condition, + ) + + def enter_conditional_section(self) -> Builder: + # logging.debug("Creating a nested condition") + return self.new_builder().enter_conditional_section() + + def with_execution_state(self, es: ExecutionState) -> Builder: + return self.new_builder().with_execution_state(es) + + def with_compilation_state(self, c: CompilationState) -> Builder: + return self.new_builder().with_compilation_state(c) + + def with_new_compilation_state(self) -> Builder: + return self.with_compilation_state(self.new_compilation_state()) + + def with_file_access(self, fa: FileAccessProvider) -> Builder: + return self.new_builder().with_file_access(fa) + + def with_serialization_settings(self, ss: SerializationSettings) -> Builder: + return self.new_builder().with_serialization_settings(ss) + + def new_compilation_state(self, prefix: str = "") -> CompilationState: + """ + Creates and returns a default compilation state. For most of the code this should be the entrypoint + of compilation, otherwise the code should always uses - with_compilation_state + """ + return CompilationState(prefix=prefix) + + def new_execution_state(self, working_dir: Optional[os.PathLike] = None) -> ExecutionState: + """ + Creates and returns a new default execution state. This should be used at the entrypoint of execution, + in all other cases it is preferable to use with_execution_state + """ + if not working_dir: + working_dir = self.file_access.local_sandbox_dir + return ExecutionState(working_dir=working_dir, user_space_params=self.user_space_params) + + @staticmethod + def current_context() -> FlyteContext: + """ + This method exists only to maintain backwards compatibility. Please use + ``FlyteContextManager.current_context()`` instead. + + Users of flytekit should be wary not to confuse the object returned from this function + with :py:func:`flytekit.current_context` + """ + return FlyteContextManager.current_context() + + def get_deck(self) -> typing.Union[str, "IPython.core.display.HTML"]: # type:ignore + """ + Returns the deck that was created as part of the last execution. + + The return value depends on the execution environment. In a notebook, the return value is compatible with + IPython.display and should be rendered in the notebook. + + .. code-block:: python + + with flytekit.new_context() as ctx: + my_task(...) + ctx.get_deck() + + OR if you wish to explicitly display + + .. code-block:: python + + from IPython import display + display(ctx.get_deck()) + """ + from flytekit.deck.deck import _get_deck + + return _get_deck(typing.cast(ExecutionState, self.execution_state).user_space_params) + + @dataclass + class Builder(object): + file_access: FileAccessProvider + level: int = 0 + compilation_state: Optional[CompilationState] = None + execution_state: Optional[ExecutionState] = None + flyte_client: Optional["friendly_client.SynchronousFlyteClient"] = None + serialization_settings: Optional[SerializationSettings] = None + in_a_condition: bool = False + + def build(self) -> FlyteContext: + return FlyteContext( + level=self.level + 1, + file_access=self.file_access, + compilation_state=self.compilation_state, + execution_state=self.execution_state, + flyte_client=self.flyte_client, + serialization_settings=self.serialization_settings, + in_a_condition=self.in_a_condition, + ) + + def enter_conditional_section(self) -> FlyteContext.Builder: + """ + Used by the condition block to indicate that a new conditional section has been started. + """ + + if self.compilation_state: + self.compilation_state = self.compilation_state.with_params(prefix=self.compilation_state.prefix) + + if self.execution_state: + if self.execution_state.is_local_execution(): + if self.in_a_condition: + if self.execution_state.branch_eval_mode == BranchEvalMode.BRANCH_SKIPPED: + self.execution_state = self.execution_state.with_params() + else: + # In case of local workflow execution we should ensure a conditional section + # is created so that skipped branches result in tasks not being executed + self.execution_state = self.execution_state.with_params( + branch_eval_mode=BranchEvalMode.BRANCH_SKIPPED + ) + + self.in_a_condition = True + return self + + def with_execution_state(self, es: ExecutionState) -> FlyteContext.Builder: + self.execution_state = es + return self + + def with_compilation_state(self, c: CompilationState) -> FlyteContext.Builder: + self.compilation_state = c + return self + + def with_new_compilation_state(self) -> FlyteContext.Builder: + return self.with_compilation_state(self.new_compilation_state()) + + def with_file_access(self, fa: FileAccessProvider) -> FlyteContext.Builder: + self.file_access = fa + return self + + def with_serialization_settings(self, ss: SerializationSettings) -> FlyteContext.Builder: + self.serialization_settings = ss + return self + + def new_compilation_state(self, prefix: str = "") -> CompilationState: + """ + Creates and returns a default compilation state. For most of the code this should be the entrypoint + of compilation, otherwise the code should always uses - with_compilation_state + """ + return CompilationState(prefix=prefix) + + def new_execution_state(self, working_dir: Optional[Union[os.PathLike, str]] = None) -> ExecutionState: + """ + Creates and returns a new default execution state. This should be used at the entrypoint of execution, + in all other cases it is preferable to use with_execution_state + """ + if not working_dir: + working_dir = self.file_access.get_random_local_directory() + return ExecutionState(working_dir=working_dir) + + +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 more information. FlyteContextManager provides a singleton stack to manage these contexts. + + Typical usage is + + .. code-block:: python + + FlyteContextManager.initialize() + with FlyteContextManager.with_context(o) as ctx: + pass + + # If required - not recommended you can use + FlyteContextManager.push_context() + # but correspondingly a pop_context should be called + FlyteContextManager.pop_context() + """ + + @staticmethod + def get_origin_stackframe(limit=2) -> traceback.FrameSummary: + ss = traceback.extract_stack(limit=limit + 1) + if len(ss) > limit + 1: + return ss[limit] + return ss[0] + + @staticmethod + def current_context() -> FlyteContext: + if not flyte_context_Var.get(): + # we will lost the default flyte context in the new thread. Therefore, reinitialize the context when running in the new thread. + FlyteContextManager.initialize() + return flyte_context_Var.get()[-1] + + @staticmethod + def push_context(ctx: FlyteContext, f: Optional[traceback.FrameSummary] = None) -> FlyteContext: + if not f: + f = FlyteContextManager.get_origin_stackframe(limit=2) + ctx.set_stackframe(f) + context_list = flyte_context_Var.get() + context_list.append(ctx) + flyte_context_Var.set(context_list) + t = "\t" + logger.debug( + f"{t * ctx.level}[{len(flyte_context_Var.get())}] Pushing context - {'compile' if ctx.compilation_state else 'execute'}, branch[{ctx.in_a_condition}], {ctx.get_origin_stackframe_repr()}" + ) + return ctx + + @staticmethod + def pop_context() -> FlyteContext: + context_list = flyte_context_Var.get() + ctx = context_list.pop() + flyte_context_Var.set(context_list) + t = "\t" + logger.debug( + f"{t * ctx.level}[{len(flyte_context_Var.get()) + 1}] Popping context - {'compile' if ctx.compilation_state else 'execute'}, branch[{ctx.in_a_condition}], {ctx.get_origin_stackframe_repr()}" + ) + if len(flyte_context_Var.get()) == 0: + raise AssertionError(f"Illegal Context state! Popped, {ctx}") + return ctx + + @staticmethod + @contextmanager + def with_context(b: FlyteContext.Builder) -> Generator[FlyteContext, None, None]: + ctx = FlyteContextManager.push_context(b.build(), FlyteContextManager.get_origin_stackframe(limit=3)) + l = FlyteContextManager.size() + try: + yield ctx + finally: + # NOTE: Why? Do we have a loop here to ensure that we are popping all context up to the previously recorded + # length? This is because it is possible that a conditional context may have leaked. Because of the syntax + # of conditionals, if a conditional section fails to evaluate / compile, the context is not removed from the + # stack. This is because context managers cannot be used in the conditional section. + # conditional().if_(...)...... + # Ideally we should have made conditional like so + # with conditional() as c + # c.if_()..... + # the reason why we did not do that, was because, of the brevity and the assignment of outputs. Also, nested + # conditionals using the context manager syntax is not easy to follow. So we wanted to optimize for the user + # ergonomics + # Also we know that top level construct like workflow and tasks always use context managers and that + # context manager mutations are single threaded, hence we can safely cleanup leaks in this section + # Also this is only in the error cases! + while FlyteContextManager.size() >= l: + FlyteContextManager.pop_context() + + @staticmethod + def size() -> int: + return len(flyte_context_Var.get()) + + @staticmethod + def initialize(): + """ + Re-initializes the context and erases the entire context + """ + # This is supplied so that tasks that rely on Flyte provided param functionality do not fail when run locally + default_execution_id = _identifier.WorkflowExecutionIdentifier(project="local", domain="local", name="local") + + cfg = Config.auto() + # Ensure a local directory is available for users to work with. + user_space_path = os.path.join(cfg.local_sandbox_path, "user_space") + pathlib.Path(user_space_path).mkdir(parents=True, exist_ok=True) + + # Note we use the SdkWorkflowExecution object purely for formatting into the ex:project:domain:name format users + # are already acquainted with + default_context = FlyteContext(file_access=default_local_file_access_provider) + default_user_space_params = ExecutionParameters( + execution_id=WorkflowExecutionIdentifier.promote_from_model(default_execution_id), + task_id=_identifier.Identifier(_identifier.ResourceType.TASK, "local", "local", "local", "local"), + execution_date=_datetime.datetime.utcnow(), + stats=mock_stats.MockStats(), + logging=user_space_logger, + tmp_dir=user_space_path, + raw_output_prefix=default_context.file_access._raw_output_prefix, + decks=[], + ) + + default_context = default_context.with_execution_state( + default_context.new_execution_state().with_params(user_space_params=default_user_space_params) + ).build() + default_context.set_stackframe(s=FlyteContextManager.get_origin_stackframe()) + flyte_context_Var.set([default_context]) + + +class FlyteEntities(object): + """ + This is a global Object that tracks various tasks and workflows that are declared within a VM during the + registration process + """ + + entities: List[Union["LaunchPlan", Task, "WorkflowBase"]] = [] # type: ignore + + +FlyteContextManager.initialize() diff --git a/flytekit/flytekit/core/data_persistence.py b/flytekit/flytekit/core/data_persistence.py new file mode 100644 index 0000000000..f07fc727e6 --- /dev/null +++ b/flytekit/flytekit/core/data_persistence.py @@ -0,0 +1,526 @@ +""" +====================================== +:mod:`flytekit.core.data_persistence` +====================================== + +.. currentmodule:: flytekit.core.data_persistence + +The Data persistence module is used by core flytekit and most of the core TypeTransformers to manage data fetch & store, +between the durable backend store and the runtime environment. This is designed to be a pluggable system, with a default +simple implementation that ships with the core. + +.. autosummary:: + :toctree: generated/ + :template: custom.rst + :nosignatures: + + FileAccessProvider + +""" +import io +import os +import pathlib +import tempfile +import typing +from typing import Any, Dict, Optional, Union, cast +from uuid import UUID + +import fsspec +from fsspec.utils import get_protocol +from typing_extensions import Unpack + +from flytekit import configuration +from flytekit.configuration import DataConfig +from flytekit.core.local_fsspec import FlyteLocalFileSystem +from flytekit.core.utils import timeit +from flytekit.exceptions.user import FlyteAssertion, FlyteValueException +from flytekit.interfaces.random import random +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" +_ANON = "anon" + +Uploadable = typing.Union[str, os.PathLike, pathlib.Path, bytes, io.BufferedReader, io.BytesIO, io.StringIO] + + +def s3_setup_args(s3_cfg: configuration.S3Config, anonymous: bool = False) -> Dict[str, Any]: + kwargs: Dict[str, Any] = { + "cache_regions": True, + } + 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} + + if anonymous: + kwargs[_ANON] = True + + return kwargs + + +def azure_setup_args(azure_cfg: configuration.AzureBlobStorageConfig, anonymous: bool = False) -> Dict[str, Any]: + kwargs: Dict[str, Any] = {} + + if azure_cfg.account_name: + kwargs["account_name"] = azure_cfg.account_name + if azure_cfg.account_key: + kwargs["account_key"] = azure_cfg.account_key + if azure_cfg.client_id: + kwargs["client_id"] = azure_cfg.client_id + if azure_cfg.client_secret: + kwargs["client_secret"] = azure_cfg.client_secret + if azure_cfg.tenant_id: + kwargs["tenant_id"] = azure_cfg.tenant_id + kwargs[_ANON] = anonymous + return kwargs + + +def get_fsspec_storage_options( + protocol: str, data_config: typing.Optional[DataConfig] = None, anonymous: bool = False, **kwargs +) -> Dict[str, Any]: + data_config = data_config or DataConfig.auto() + + if protocol == "file": + return {"auto_mkdir": True, **kwargs} + if protocol == "s3": + return {**s3_setup_args(data_config.s3, anonymous=anonymous), **kwargs} + if protocol == "gs": + if anonymous: + kwargs["token"] = _ANON + return kwargs + if protocol in ("abfs", "abfss"): + return {**azure_setup_args(data_config.azure, anonymous=anonymous), **kwargs} + return {} + + +class FileAccessProvider(object): + """ + This is the class that is available through the FlyteContext and can be used for persisting data to the remote + durable store. + """ + + def __init__( + self, + local_sandbox_dir: Union[str, os.PathLike], + raw_output_prefix: str, + data_config: typing.Optional[DataConfig] = None, + ): + """ + Args: + local_sandbox_dir: A local temporary working directory, that should be used to store data + raw_output_prefix: + data_config: + """ + # Local access + if local_sandbox_dir is None or local_sandbox_dir == "": + raise ValueError("FileAccessProvider needs to be created with a valid local_sandbox_dir") + 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 = fsspec.filesystem(None) + + self._data_config = data_config if data_config else DataConfig.auto() + self._default_protocol = get_protocol(str(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: + return self._raw_output_prefix + + @property + def data_config(self) -> DataConfig: + return self._data_config + + @property + def raw_output_fs(self) -> fsspec.AbstractFileSystem: + """ + Returns a file system corresponding to the provided raw output prefix + """ + return self._default_remote + + def get_filesystem( + self, protocol: typing.Optional[str] = None, anonymous: bool = False, **kwargs + ) -> fsspec.AbstractFileSystem: + if not protocol: + return self._default_remote + if protocol == "file": + kwargs["auto_mkdir"] = True + return FlyteLocalFileSystem(**kwargs) + 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 + + storage_options = get_fsspec_storage_options( + protocol=protocol, anonymous=anonymous, data_config=self._data_config, **kwargs + ) + + return fsspec.filesystem(protocol, **storage_options) + + 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. Let's find a replacement + """ + protocol = get_protocol(str(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) -> fsspec.AbstractFileSystem: + return self._local + + @staticmethod + def strip_file_header(path: str, trim_trailing_sep: bool = False) -> str: + """ + Drops file:// if it exists from the file + """ + if path.startswith("file://"): + return path.replace("file://", "", 1) + return path + + @staticmethod + def recursive_paths(f: str, t: str) -> typing.Tuple[str, str]: + # Only apply the join if the from_path isn't already a file. But we can do this check only + # for local files, otherwise assume it's a directory and add /'s as usual + if get_protocol(f) == "file": + local_fs = fsspec.filesystem("file") + if local_fs.exists(f) and local_fs.isdir(f): + print("Adding trailing sep to") + f = os.path.join(f, "") + else: + print("Not adding trailing sep") + else: + 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 + if isinstance(file_system.protocol, tuple) or isinstance(file_system.protocol, list): + if "file" in file_system.protocol: + 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, **kwargs): + file_system = self.get_filesystem_for_path(from_path) + if recursive: + from_path, to_path = self.recursive_paths(from_path, to_path) + try: + if os.name == "nt" and file_system.protocol == "file" and recursive: + import shutil + + return shutil.copytree( + self.strip_file_header(from_path), self.strip_file_header(to_path), dirs_exist_ok=True + ) + logger.info(f"Getting {from_path} to {to_path}") + dst = file_system.get(from_path, to_path, recursive=recursive, **kwargs) + if isinstance(dst, (str, pathlib.Path)): + return dst + return to_path + except OSError as oe: + logger.debug(f"Error in getting {from_path} to {to_path} rec {recursive} {oe}") + if not file_system.exists(from_path): + raise FlyteValueException(from_path, "File not found") + 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, **kwargs) + raise oe + + def put(self, from_path: str, to_path: str, recursive: bool = False, **kwargs): + file_system = self.get_filesystem_for_path(to_path) + from_path = self.strip_file_header(from_path) + if recursive: + # Only check this for the local filesystem + if file_system.protocol == "file" and not file_system.isdir(from_path): + raise FlyteAssertion(f"Source path {from_path} is not a directory") + if os.name == "nt" and file_system.protocol == "file": + import shutil + + return shutil.copytree( + self.strip_file_header(from_path), self.strip_file_header(to_path), dirs_exist_ok=True + ) + from_path, to_path = self.recursive_paths(from_path, to_path) + dst = file_system.put(from_path, to_path, recursive=recursive, **kwargs) + if isinstance(dst, (str, pathlib.Path)): + return dst + else: + return to_path + + def put_raw_data( + self, + lpath: Uploadable, + upload_prefix: Optional[str] = None, + file_name: Optional[str] = None, + read_chunk_size_bytes: int = 1024, + encoding: str = "utf-8", + **kwargs, + ) -> str: + """ + This is a more flexible version of put that accepts a file-like object or a string path. + Writes to the raw output prefix only. If you want to write to another fs use put_data or get the fsspec + file system directly. + FYI: Currently the raw output prefix set by propeller is already unique per retry and looks like + s3://my-s3-bucket/data/o4/feda4e266c748463a97d-n0-0 + + If lpath is a folder, then recursive will be set. + If lpath is a streamable, then it can only be a single file. + + Writes to: + // + + :param lpath: A file-like object or a string path + :param upload_prefix: A prefix to add to the path, see above for usage, can be an "". If None then a random + string will be generated + :param file_name: A file name to add to the path. If None, then the file name will be the tail of the path if + lpath is a file, or a random string if lpath is a buffer + :param read_chunk_size_bytes: If lpath is a buffer, this is the chunk size to read from it + :param encoding: If lpath is a io.StringIO, this is the encoding to use to encode it to binary. + :param kwargs: Additional kwargs are passed into the the fsspec put() call or the open() call + :return: Returns the final path data was written to. + """ + # First figure out what the destination path should be, then call put. + upload_prefix = self.get_random_string() if upload_prefix is None else upload_prefix + to_path = self.join(self.raw_output_prefix, upload_prefix) + if file_name: + to_path = self.join(to_path, file_name) + else: + if isinstance(lpath, str) or isinstance(lpath, os.PathLike) or isinstance(lpath, pathlib.Path): + to_path = self.join(to_path, self.get_file_tail(str(lpath))) + else: + to_path = self.join(to_path, self.get_random_string()) + + # If lpath is a file, then use put. + if isinstance(lpath, str) or isinstance(lpath, os.PathLike) or isinstance(lpath, pathlib.Path): + p = pathlib.Path(lpath) + from_path = str(lpath) + if not p.exists(): + raise FlyteAssertion(f"File {from_path} does not exist") + elif p.is_symlink(): + raise FlyteAssertion(f"File {from_path} is a symlink, can't upload") + if p.is_dir(): + logger.debug(f"Detected directory {from_path}, using recursive put") + r = self.put(from_path, to_path, recursive=True, **kwargs) + else: + logger.debug(f"Detected file {from_path}, call put non-recursive") + r = self.put(from_path, to_path, **kwargs) + return r or to_path + + # raw bytes + if isinstance(lpath, bytes): + fs = self.get_filesystem_for_path(to_path) + with fs.open(to_path, "wb", **kwargs) as s: + s.write(lpath) + return to_path + + # If lpath is a buffered reader of some kind + if isinstance(lpath, io.BufferedReader) or isinstance(lpath, io.BytesIO): + if not lpath.readable(): + raise FlyteAssertion("Buffered reader must be readable") + fs = self.get_filesystem_for_path(to_path) + lpath.seek(0) + with fs.open(to_path, "wb", **kwargs) as s: + while data := lpath.read(read_chunk_size_bytes): + s.write(data) + return to_path + + if isinstance(lpath, io.StringIO): + if not lpath.readable(): + raise FlyteAssertion("Buffered reader must be readable") + fs = self.get_filesystem_for_path(to_path) + lpath.seek(0) + with fs.open(to_path, "wb", **kwargs) as s: + while data_str := lpath.read(read_chunk_size_bytes): + s.write(data_str.encode(encoding)) + return to_path + + raise FlyteAssertion(f"Unsupported lpath type {type(lpath)}") + + @staticmethod + def get_random_string() -> str: + return UUID(int=random.getrandbits(128)).hex + + @staticmethod + def get_file_tail(file_path_or_file_name: str) -> str: + _, tail = os.path.split(file_path_or_file_name) + return tail + + def join( + self, + *args: Unpack[str], # type: ignore + unstrip: bool = False, + fs: typing.Optional[fsspec.AbstractFileSystem] = None, + ) -> str: + # todo add a check here for flyte fs + fs = fs or self.raw_output_fs + if len(args) == 0: + raise ValueError("Must provide at least one argument") + base, tails = args[0], list(args[1:]) + if get_protocol(base) not in str(fs.protocol): + logger.warning(f"joining {base} with incorrect fs {fs.protocol} vs {get_protocol(base)}") + if base.endswith(fs.sep): # noqa + base = base[:-1] + l = [base] + l.extend(tails) + f = fs.sep.join(l) + if unstrip: + f = fs.unstrip_protocol(f) + return f + + def get_random_local_path(self, file_path_or_file_name: typing.Optional[str] = None) -> str: + """ + Use file_path_or_file_name, when you want a random directory, but want to preserve the leaf 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 get_random_remote_path(self, file_path_or_file_name: typing.Optional[str] = None) -> str: + if file_path_or_file_name: + return self.join( + self.raw_output_prefix, + self.get_random_string(), + self.get_file_tail(file_path_or_file_name), + ) + return self.join( + self.raw_output_prefix, + self.get_random_string(), + ) + + def get_random_remote_directory(self) -> str: + return self.join( + self.raw_output_prefix, + self.get_random_string(), + ) + + def download_directory(self, remote_path: str, local_path: str, **kwargs): + """ + Downloads directory from given remote to local path + """ + return self.get_data(remote_path, local_path, is_multipart=True) + + def download(self, remote_path: str, local_path: str, **kwargs): + """ + Downloads from remote to local + """ + return self.get_data(remote_path, local_path, **kwargs) + + def upload(self, file_path: str, to_path: str, **kwargs): + """ + :param Text file_path: + :param Text to_path: + """ + return self.put_data(file_path, to_path, **kwargs) + + def upload_directory(self, local_path: str, remote_path: str, **kwargs): + """ + :param Text local_path: + :param Text remote_path: + """ + return self.put_data(local_path, remote_path, is_multipart=True, **kwargs) + + def get_data(self, remote_path: str, local_path: str, is_multipart: bool = False, **kwargs): + """ + :param remote_path: + :param local_path: + :param is_multipart: + """ + try: + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + with timeit(f"Download data to local from {remote_path}"): + self.get(remote_path, to_path=local_path, recursive=is_multipart, **kwargs) + 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: bool = False, **kwargs + ) -> str: + """ + 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 local_path: + :param remote_path: + :param is_multipart: + """ + try: + local_path = str(local_path) + with timeit(f"Upload data to {remote_path}"): + put_result = self.put(cast(str, local_path), remote_path, recursive=is_multipart, **kwargs) + # This is an unfortunate workaround to ensure that we return the correct path for the remote location + # Callers of this put_data function in flytekit have been changed to assign the remote path to the + # output + # of this function, so we want to make sure we don't change it unless we need to. + if remote_path.startswith("flyte://"): + return put_result + return remote_path + except Exception as ex: + raise FlyteAssertion( + f"Failed to put data from {local_path} to {remote_path} (recursive={is_multipart}).\n\n" + f"Original exception: {str(ex)}" + ) from ex + + +flyte_tmp_dir = tempfile.mkdtemp(prefix="flyte-") +default_local_file_access_provider = FileAccessProvider( + local_sandbox_dir=os.path.join(flyte_tmp_dir, "sandbox"), + raw_output_prefix=os.path.join(flyte_tmp_dir, "raw"), + data_config=DataConfig.auto(), +) diff --git a/flytekit/flytekit/core/docstring.py b/flytekit/flytekit/core/docstring.py new file mode 100644 index 0000000000..fa9d9caec2 --- /dev/null +++ b/flytekit/flytekit/core/docstring.py @@ -0,0 +1,27 @@ +from typing import Callable, Dict, Optional + +from docstring_parser import parse + + +class Docstring(object): + def __init__(self, docstring: Optional[str] = None, callable_: Optional[Callable] = None): + if docstring is not None: + self._parsed_docstring = parse(docstring) + else: + self._parsed_docstring = parse(callable_.__doc__) + + @property + def input_descriptions(self) -> Dict[str, str]: + return {p.arg_name: p.description for p in self._parsed_docstring.params} + + @property + def output_descriptions(self) -> Dict[str, str]: + return {p.return_name: p.description for p in self._parsed_docstring.many_returns} + + @property + def short_description(self) -> Optional[str]: + return self._parsed_docstring.short_description + + @property + def long_description(self) -> Optional[str]: + return self._parsed_docstring.long_description diff --git a/flytekit/flytekit/core/dynamic_workflow_task.py b/flytekit/flytekit/core/dynamic_workflow_task.py new file mode 100644 index 0000000000..a0f84927bf --- /dev/null +++ b/flytekit/flytekit/core/dynamic_workflow_task.py @@ -0,0 +1,53 @@ +""" +Dynamic Workflows +----------------------------- +Dynamic workflows are one of the powerful aspects of Flyte. Please take a look at the :py:func:`flytekit.dynamic` documentation first to get started. + + +Caveats when using a dynamic workflow +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Because of the dynamic nature of the workflow generated, it can easily be abused. Keep in mind that the workflow +that's compiled out of the decorated function needs to be processed like any other workflow. It's rare to see a +manually written workflow that has 5000 nodes for instance, but you can easily get there with a loop. Please keep, +dynamic workflows to under fifty tasks. For large-scale identical runs, we recommend the upcoming map task. + +""" +import functools + +from flytekit.core import task +from flytekit.core.python_function_task import PythonFunctionTask + +dynamic = functools.partial(task.task, execution_mode=PythonFunctionTask.ExecutionBehavior.DYNAMIC) # type: ignore[var-annotated] +dynamic.__doc__ = """ +Please first see the comments for :py:func:`flytekit.task` and :py:func:`flytekit.workflow`. This ``dynamic`` +concept is an amalgamation of both and enables the user to pursue some :std:ref:`pretty incredible ` +constructs. + +In short, a task's function is run at execution time only, and a workflow function is run at compilation time only (local +execution notwithstanding). A dynamic workflow is modeled on the backend as a task, but at execution time, the function +body is run to produce a workflow. It is almost as if the decorator changed from ``@task`` to ``@workflow`` except workflows +cannot make use of their inputs like native Python values whereas dynamic workflows can. +The resulting workflow is passed back to the Flyte engine and is +run as a :std:ref:`subworkflow `. Simple usage + +.. code-block:: + + @dynamic + def my_dynamic_subwf(a: int) -> (typing.List[str], int): + s = [] + for i in range(a): + s.append(t1(a=i)) + return s, 5 + +Note in the code block that we call the Python ``range`` operator on the input. This is typically not allowed in a +workflow but it is here. You can even express dependencies between tasks. + +.. code-block:: + + @dynamic + def my_dynamic_subwf(a: int, b: int) -> int: + x = t1(a=a) + return t2(b=b, x=x) + +See the :std:ref:`cookbook ` for a longer discussion. +""" # noqa: W293 diff --git a/flytekit/flytekit/core/gate.py b/flytekit/flytekit/core/gate.py new file mode 100644 index 0000000000..7685165743 --- /dev/null +++ b/flytekit/flytekit/core/gate.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import datetime +import typing +from typing import Tuple, Union + +import click + +from flytekit.core import constants +from flytekit.core import interface as flyte_interface +from flytekit.core.context_manager import ExecutionState, FlyteContext, FlyteContextManager +from flytekit.core.promise import Promise, VoidPromise, flyte_entity_call_handler +from flytekit.core.type_engine import TypeEngine +from flytekit.exceptions.user import FlyteDisapprovalException +from flytekit.interaction.parse_stdin import parse_stdin_to_literal +from flytekit.interaction.string_literals import scalar_to_string +from flytekit.models.core import workflow as _workflow_model +from flytekit.models.literals import Scalar +from flytekit.models.types import LiteralType + +DEFAULT_TIMEOUT = datetime.timedelta(hours=1) + + +class Gate(object): + """ + A node type that waits for user input before proceeding with a workflow. + A gate is a type of node that behaves like a task, but instead of running code, it either needs to wait + for user input to proceed or wait for a timer to complete running. + """ + + def __init__( + self, + name: str, + input_type: typing.Optional[typing.Type] = None, + upstream_item: typing.Optional[typing.Any] = None, + sleep_duration: typing.Optional[datetime.timedelta] = None, + timeout: typing.Optional[datetime.timedelta] = None, + ): + self._name = name + self._input_type = input_type + self._sleep_duration = sleep_duration + self._timeout = timeout or DEFAULT_TIMEOUT + self._upstream_item = upstream_item + self._literal_type = TypeEngine.to_literal_type(input_type) if input_type else None + + # Determine the python interface if we can + if self._sleep_duration: + # Just a sleep so there is no interface + self._python_interface = flyte_interface.Interface() + elif input_type: + # Waiting for user input, so the output of the node is whatever input the user provides. + self._python_interface = flyte_interface.Interface( + outputs={ + "o0": self.input_type, + } + ) + else: + # We don't know how to find the python interface here, approve() sets it below, See the code. + self._python_interface = None # type: ignore + + @property + def name(self) -> str: + # Part of SupportsNodeCreation interface + return self._name + + @property + def input_type(self) -> typing.Optional[typing.Type]: + return self._input_type + + @property + def literal_type(self) -> typing.Optional[LiteralType]: + return self._literal_type + + @property + def sleep_duration(self) -> typing.Optional[datetime.timedelta]: + return self._sleep_duration + + @property + def python_interface(self) -> flyte_interface.Interface: + """ + This will not be valid during local execution + Part of SupportsNodeCreation interface + """ + # If this is just a sleep node, or user input node, then it will have a Python interface upon construction. + if self._python_interface: + return self._python_interface + + raise ValueError("You can't check for a Python interface for an approval node outside of compilation") + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + # Part of SupportsNodeCreation interface + return _workflow_model.NodeMetadata( + name=self.name, + timeout=self._timeout, + ) + + # This is to satisfy the LocallyExecutable protocol + def local_execute(self, ctx: FlyteContext, **kwargs) -> Union[Tuple[Promise], Promise, VoidPromise]: + if self.sleep_duration: + click.echo( + f'{click.style("[Sleep Gate]", fg="yellow")} ' + f'{click.style(f"Simulating Sleep for {self.sleep_duration}", fg="cyan")}' + ) + return VoidPromise(self.name) + + # Trigger stdin + if self.input_type: + msg = click.style("[Input Gate] ", fg="yellow") + click.style( + f"Waiting for input @{self.name} of type {self.input_type}", fg="cyan" + ) + literal = parse_stdin_to_literal(ctx, self.input_type, msg) + p = Promise(var="o0", val=literal) + return p + + # Assume this is an approval operation since that's the only remaining option. + v = typing.cast(Promise, self._upstream_item).val.value + if isinstance(v, Scalar): + v = scalar_to_string(v) + msg = click.style("[Approval Gate] ", fg="yellow") + click.style( + f"@{self.name} Approve {click.style(v, fg='green')}?", fg="cyan" + ) + proceed = click.confirm(msg, default=True) + if proceed: + # We need to return a promise here, and a promise is what should've been passed in by the call in approve() + # Only one element should be in this map. Rely on kwargs instead of the stored _upstream_item even though + # they should be the same to be cleaner + output_name = list(kwargs.keys())[0] + return kwargs[output_name] + else: + raise FlyteDisapprovalException(f"User did not approve the transaction for gate node {self.name}") + + def local_execution_mode(self): + return ExecutionState.Mode.LOCAL_TASK_EXECUTION + + +def wait_for_input(name: str, timeout: datetime.timedelta, expected_type: typing.Type): + """Create a Gate object that waits for user input of the specified type. + + Create a Gate object. This object will function like a task. Note that unlike a task, + each time this function is called, a new Python object is created. If a workflow + calls a subworkflow twice, and the subworkflow has a signal, then two Gate + objects are created. This shouldn't be a problem as long as the objects are identical. + + :param name: The name of the gate node. + :param timeout: How long to wait for before Flyte fails the workflow. + :param expected_type: What is the type that the user will be inputting? + :return: + """ + + g = Gate(name, input_type=expected_type, timeout=timeout) + + return flyte_entity_call_handler(g) + + +def sleep(duration: datetime.timedelta): + """Create a sleep Gate object. + + :param duration: How long to sleep for + :return: + """ + g = Gate("sleep-gate", sleep_duration=duration) + + return flyte_entity_call_handler(g) + + +def approve(upstream_item: Union[Tuple[Promise], Promise, VoidPromise], name: str, timeout: datetime.timedelta): + """Create a Gate object for binary approval. + + Create a Gate object. This object will function like a task. Note that unlike a task, + each time this function is called, a new Python object is created. If a workflow + calls a subworkflow twice, and the subworkflow has a signal, then two Gate + objects are created. This shouldn't be a problem as long as the objects are identical. + + :param upstream_item: This should be the output, one output, of a previous task, that you want to gate execution + on. This is the value that you want a human to check before moving on. + :param name: The name of the gate node. + :param timeout: How long to wait before Flyte fails the workflow. + :return: + """ + g = Gate(name, upstream_item=upstream_item, timeout=timeout) + + if upstream_item is None or isinstance(upstream_item, VoidPromise): + raise ValueError("You can't use approval on a task that doesn't return anything.") + + ctx = FlyteContextManager.current_context() + upstream_item = typing.cast(Promise, upstream_item) + if ctx.compilation_state is not None and ctx.compilation_state.mode == 1: + if upstream_item.ref.node_id == constants.GLOBAL_INPUT_NODE_ID: + raise ValueError("Workflow inputs cannot be passed to approval nodes.") + if not upstream_item.ref.node.flyte_entity.python_interface: + raise ValueError( + f"Upstream node doesn't have a Python interface. Node entity is: " + f"{upstream_item.ref.node.flyte_entity}" + ) + + # We have reach back up to the entity that this promise came from, to get the python type, since + # the approve function itself doesn't have a python interface. + io_type = upstream_item.ref.node.flyte_entity.python_interface.outputs[upstream_item.var] + io_var_name = upstream_item.var + else: + # We don't know the python type here. in local execution, downstream doesn't really use the type + # so we should be okay. But use None instead of type() so that errors are more obvious hopefully. + io_type = None + io_var_name = "o0" + + # In either case, we need a python interface + g._python_interface = flyte_interface.Interface( + inputs={ + io_var_name: io_type, + }, + outputs={ + io_var_name: io_type, + }, + ) + kwargs = {io_var_name: upstream_item} + + return flyte_entity_call_handler(g, **kwargs) diff --git a/flytekit/flytekit/core/hash.py b/flytekit/flytekit/core/hash.py new file mode 100644 index 0000000000..2aca94e846 --- /dev/null +++ b/flytekit/flytekit/core/hash.py @@ -0,0 +1,23 @@ +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + + +class HashOnReferenceMixin(object): + def __hash__(self): + return hash(id(self)) + + +class HashMethod(Generic[T]): + """ + Flyte-specific object used to wrap the hash function for a specific type + """ + + def __init__(self, function: Callable[[T], str]): + self._function = function + + def calculate(self, obj: T) -> str: + """ + Calculate hash for `obj`. + """ + return self._function(obj) diff --git a/flytekit/flytekit/core/interface.py b/flytekit/flytekit/core/interface.py new file mode 100644 index 0000000000..24cfd24581 --- /dev/null +++ b/flytekit/flytekit/core/interface.py @@ -0,0 +1,499 @@ +from __future__ import annotations + +import collections +import copy +import inspect +import typing +from collections import OrderedDict +from typing import Any, Dict, Generator, List, Optional, Tuple, Type, TypeVar, Union, cast + +from flyteidl.core import artifact_id_pb2 as art_id +from typing_extensions import get_args, get_origin, get_type_hints + +from flytekit.core import context_manager +from flytekit.core.artifact import Artifact, ArtifactIDSpecification, ArtifactQuery +from flytekit.core.docstring import Docstring +from flytekit.core.type_engine import TypeEngine +from flytekit.exceptions.user import FlyteValidationException +from flytekit.loggers import logger +from flytekit.models import interface as _interface_models +from flytekit.models.literals import Literal, Scalar, Void + +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. + """ + + def __init__( + self, + inputs: Union[Optional[Dict[str, Type]], Optional[Dict[str, Tuple[Type, Any]]]] = None, + outputs: Union[Optional[Dict[str, Type]], Optional[Dict[str, Optional[Type]]]] = None, + output_tuple_name: Optional[str] = None, + docstring: Optional[Docstring] = None, + ): + """ + :param outputs: Output variables and their types as a dictionary + :param inputs: Map of input name to either a tuple where the first element is the python type, and the second + value is the default, or just a single value which is the python type. The latter case is used by tasks + for which perhaps a default value does not make sense. For consistency, we turn it into a tuple. + :param output_tuple_name: This is used to store the name of a typing.NamedTuple when the task or workflow + returns one. This is also used as a proxy for better or for worse for the presence of a tuple return type, + primarily used when handling one-element NamedTuples. + :param docstring: Docstring of the annotated @task or @workflow from which the interface derives from. + """ + self._inputs: Union[Dict[str, Tuple[Type, Any]], Dict[str, Type]] = {} # type: ignore + if inputs: + for k, v in inputs.items(): + if type(v) is tuple and len(cast(Tuple, v)) > 1: + self._inputs[k] = v # type: ignore + else: + self._inputs[k] = (v, None) # type: ignore + self._outputs = outputs if outputs else {} # type: ignore + self._output_tuple_name = output_tuple_name + + if outputs: + 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( # 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. + For manual node creation, it's used during local execution as something that can be dereferenced. + See the create_node function for more information. + """ + + def with_overrides(self, *args, **kwargs): + val = self.__getattribute__(self._fields[0]) + val.with_overrides(*args, **kwargs) + return self + + @property + def ref(self): + for var_name in variables: + if self.__getattribute__(var_name).ref: + return self.__getattribute__(var_name).ref + return None + + def runs_before(self, *args, **kwargs): + """ + This is a placeholder and should do nothing. It is only here to enable local execution of workflows + where runs_before is manually called. + """ + + def __rshift__(self, *args, **kwargs): + ... # See runs_before + + self._output_tuple_class = Output + self._docstring = docstring + + @property + def output_tuple(self) -> Type[collections.namedtuple]: # type: ignore + return self._output_tuple_class + + @property + def output_tuple_name(self) -> Optional[str]: + return self._output_tuple_name + + @property + def inputs(self) -> Dict[str, type]: + r = {} + for k, v in self._inputs.items(): + r[k] = v[0] + return r + + @property + def output_names(self) -> Optional[List[str]]: + if self.outputs: + return [k for k in self.outputs.keys()] + return None + + @property + def inputs_with_defaults(self) -> Dict[str, Tuple[Type, Any]]: + return cast(Dict[str, Tuple[Type, Any]], self._inputs) + + @property + def default_inputs_as_kwargs(self) -> Dict[str, Any]: + return {k: v[1] for k, v in self._inputs.items() if v[1] is not None} + + @property + def outputs(self) -> typing.Dict[str, type]: + return self._outputs # type: ignore + + @property + def docstring(self) -> Optional[Docstring]: + return self._docstring + + def remove_inputs(self, vars: Optional[List[str]]) -> Interface: + """ + This method is useful in removing some variables from the Flyte backend inputs specification, as these are + implicit local only inputs or will be supplied by the library at runtime. For example, spark-session etc + It creates a new instance of interface with the requested variables removed + """ + if vars is None: + return self + new_inputs = copy.copy(self._inputs) + for v in vars: + if v in new_inputs: + del new_inputs[v] + return Interface(new_inputs, self._outputs, docstring=self.docstring) + + def with_inputs(self, extra_inputs: Dict[str, Type]) -> Interface: + """ + Use this to add additional inputs to the interface. This is useful for adding additional implicit inputs that + are added without the user requesting for them + """ + if not extra_inputs: + return self + new_inputs = copy.copy(self._inputs) + for k, v in extra_inputs.items(): + if k in new_inputs: + raise ValueError(f"Input {k} cannot be added as it already exists in the interface") + cast(Dict[str, Type], new_inputs)[k] = v + return Interface(new_inputs, self._outputs, docstring=self.docstring) + + def with_outputs(self, extra_outputs: Dict[str, Type]) -> Interface: + """ + This method allows addition of extra outputs are expected from a task specification + """ + if not extra_outputs: + return self + new_outputs = copy.copy(self._outputs) + for k, v in extra_outputs.items(): + if k in new_outputs: + raise ValueError(f"Output {k} cannot be added as it already exists in the 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 +) -> _interface_models.ParameterMap: + """ + Transforms the given interface (with inputs) to a Parameter Map with defaults set + :param ctx: context + :param interface: the interface object + """ + if interface is None or interface.inputs_with_defaults is None: + return _interface_models.ParameterMap({}) + if interface.docstring is None: + inputs_vars = transform_variable_map(interface.inputs) + else: + inputs_vars = transform_variable_map(interface.inputs, interface.docstring.input_descriptions) + params = {} + inputs_with_def = interface.inputs_with_defaults + for k, v in inputs_vars.items(): + val, _default = inputs_with_def[k] + if _default is None and get_origin(val) is typing.Union and type(None) in get_args(val): + literal = Literal(scalar=Scalar(none_type=Void())) + params[k] = _interface_models.Parameter(var=v, default=literal, required=False) + else: + if isinstance(_default, ArtifactQuery): + params[k] = _interface_models.Parameter(var=v, required=False, artifact_query=_default.to_flyte_idl()) + elif isinstance(_default, Artifact): + artifact_id = _default.concrete_artifact_id # may raise + params[k] = _interface_models.Parameter(var=v, required=False, artifact_id=artifact_id) + else: + required = _default is None + default_lv = None + if _default is not None: + default_lv = TypeEngine.to_literal(ctx, _default, python_type=interface.inputs[k], expected=v.type) + params[k] = _interface_models.Parameter(var=v, default=default_lv, required=required) + return _interface_models.ParameterMap(params) + + +def transform_interface_to_typed_interface( + interface: typing.Optional[Interface], +) -> typing.Optional[_interface_models.TypedInterface]: + """ + Transform the given simple python native interface to FlyteIDL's interface + """ + if interface is None: + return None + if interface.docstring is None: + input_descriptions = output_descriptions = {} + else: + input_descriptions = interface.docstring.input_descriptions + output_descriptions = remap_shared_output_descriptions( + interface.docstring.output_descriptions, interface.outputs + ) + + inputs_map = transform_variable_map(interface.inputs, input_descriptions) + outputs_map = transform_variable_map(interface.outputs, output_descriptions) + verify_outputs_artifact_bindings(interface.inputs, outputs_map) + return _interface_models.TypedInterface(inputs_map, outputs_map) + + +def verify_outputs_artifact_bindings(inputs: Dict[str, type], outputs: Dict[str, _interface_models.Variable]): + # collect Artifacts + for k, v in outputs.items(): + # Iterate through output partition values if any and verify that if they're bound to an input, that that input + # actually exists in the interface. + if ( + v.artifact_partial_id + and v.artifact_partial_id.HasField("partitions") + and v.artifact_partial_id.partitions.value + ): + for pk, pv in v.artifact_partial_id.partitions.value.items(): + if pv.HasField("input_binding"): + input_name = pv.input_binding.var + if input_name not in inputs: + raise FlyteValidationException( + f"Output partition {k} is bound to input {input_name} which does not exist in the interface" + ) + if v.artifact_partial_id.HasField("time_partition"): + if v.artifact_partial_id.time_partition.value.HasField("input_binding"): + input_name = v.artifact_partial_id.time_partition.value.input_binding.var + if input_name not in inputs: + raise FlyteValidationException( + f"Output time partition is bound to input {input_name} which does not exist in the interface" + ) + + +def transform_types_to_list_of_type( + m: Dict[str, type], bound_inputs: typing.Set[str], list_as_optional: bool = False +) -> Dict[str, type]: + """ + Converts unbound inputs into the equivalent (optional) collections. 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. + """ + if m is None: + return {} + + 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 + break + + if all_types_are_collection: + return m + + om = {} + for k, v in m.items(): + if k in bound_inputs: + om[k] = v + else: + om[k] = typing.List[typing.Optional[v] if list_as_optional else v] # type: ignore + return om # type: ignore + + +def transform_interface_to_list_interface( + interface: Interface, bound_inputs: typing.Set[str], optional_outputs: bool = False +) -> 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 to a 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, bound_inputs) + map_outputs = transform_types_to_list_of_type(interface.outputs, set(), optional_outputs) + + return Interface(inputs=map_inputs, outputs=map_outputs) + + +def transform_function_to_interface(fn: typing.Callable, docstring: Optional[Docstring] = None) -> Interface: + """ + From the annotations on a task function that the user should have provided, and the output names they want to use + for each output parameter, construct the TypedInterface object + + 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) + + outputs = extract_return_annotation(return_annotation) + for k, v in outputs.items(): + outputs[k] = v # type: ignore + inputs: Dict[str, Tuple[Type, Any]] = OrderedDict() + for k, v in signature.parameters.items(): # type: ignore + annotation = type_hints.get(k, None) + default = v.default if v.default is not inspect.Parameter.empty else None + # Inputs with default values are currently ignored, we may want to look into that in the future + inputs[k] = (annotation, default) # type: ignore + + # This is just for typing.NamedTuples - in those cases, the user can select a name to call the NamedTuple. We + # would like to preserve that name in our custom collections.namedtuple. + custom_name = None + if hasattr(return_annotation, "__bases__"): + bases = return_annotation.__bases__ + if len(bases) == 1 and bases[0] == tuple and hasattr(return_annotation, "_fields"): + if hasattr(return_annotation, "__name__") and return_annotation.__name__ != "": + custom_name = return_annotation.__name__ + + return Interface(inputs, outputs, output_tuple_name=custom_name, docstring=docstring) + + +def transform_variable_map( + variable_map: Dict[str, type], + descriptions: Optional[Dict[str, str]] = None, +) -> Dict[str, _interface_models.Variable]: + """ + Given a map of str (names of inputs for instance) to their Python native types, return a map of the name to a + Flyte Variable object with that type. + """ + res = OrderedDict() + descriptions = descriptions or {} + if variable_map: + for k, v in variable_map.items(): + res[k] = transform_type(v, descriptions.get(k, k)) + return res + + +def detect_artifact( + ts: typing.Tuple[typing.Any, ...], +) -> Optional[art_id.ArtifactID]: + """ + If the user wishes to control how Artifacts are created (i.e. naming them, etc.) this is where we pick it up and + store it in the interface. + """ + for t in ts: + if isinstance(t, Artifact): + id_spec = t() + return id_spec.to_partial_artifact_id() + elif isinstance(t, ArtifactIDSpecification): + artifact_id = t.to_partial_artifact_id() + return artifact_id + + return None + + +def transform_type(x: type, description: Optional[str] = None) -> _interface_models.Variable: + artifact_id = detect_artifact(get_args(x)) + if artifact_id: + logger.debug(f"Found artifact id spec: {artifact_id}") + return _interface_models.Variable( + type=TypeEngine.to_literal_type(x), description=description, artifact_partial_id=artifact_id + ) + + +def default_output_name(index: int = 0) -> str: + return f"o{index}" + + +def output_name_generator(length: int) -> Generator[str, None, None]: + for x in range(0, length): + yield default_output_name(x) + + +def extract_return_annotation(return_annotation: Union[Type, Tuple, None]) -> Dict[str, Type]: + """ + The purpose of this function is to sort out whether a function is returning one thing, or multiple things, and to + name the outputs accordingly, either by using our default name function, or from a typing.NamedTuple. + + # Option 1 + nt1 = typing.NamedTuple("NT1", x_str=str, y_int=int) + def t(a: int, b: str) -> nt1: ... + + # Option 2 + def t(a: int, b: str) -> typing.NamedTuple("NT1", x_str=str, y_int=int): ... + + # Option 3 + def t(a: int, b: str) -> typing.Tuple[int, str]: ... + + # Option 4 + def t(a: int, b: str) -> (int, str): ... + + # Option 5 + def t(a: int, b: str) -> str: ... + + # Option 6 + def t(a: int, b: str) -> None: ... + + # Options 7/8 + def t(a: int, b: str) -> List[int]: ... + def t(a: int, b: str) -> Dict[str, int]: ... + + Note that Options 1 and 2 are identical, just syntactic sugar. In the NamedTuple case, we'll use the names in the + definition. In all other cases, we'll automatically generate output names, indexed starting at 0. + """ + + # Handle Option 6 + # We can think about whether we should add a default output name with type None in the future. + if return_annotation in (None, type(None), inspect.Signature.empty): + return {} + + # This statement results in true for typing.Namedtuple, single and void return types, so this + # handles Options 1, 2. Even though NamedTuple for us is multi-valued, it's a single value for Python + if isinstance(return_annotation, Type) or isinstance(return_annotation, TypeVar): # type: ignore + # isinstance / issubclass does not work for Namedtuple. + # Options 1 and 2 + bases = return_annotation.__bases__ # type: ignore + if len(bases) == 1 and bases[0] == tuple and hasattr(return_annotation, "_fields"): + logger.debug(f"Task returns named tuple {return_annotation}") + return dict(get_type_hints(cast(Type, return_annotation), include_extras=True)) + + if hasattr(return_annotation, "__origin__") and return_annotation.__origin__ is tuple: # type: ignore + # Handle option 3 + logger.debug(f"Task returns unnamed typing.Tuple {return_annotation}") + if len(return_annotation.__args__) == 1: # type: ignore + raise FlyteValidationException( + "Tuples should be used to indicate multiple return values, found only one return variable." + ) + return OrderedDict( + zip(list(output_name_generator(len(return_annotation.__args__))), return_annotation.__args__) # type: ignore + ) + elif isinstance(return_annotation, tuple): + if len(return_annotation) == 1: + raise FlyteValidationException("Please don't use a tuple if you're just returning one thing.") + return OrderedDict(zip(list(output_name_generator(len(return_annotation))), return_annotation)) + + else: + # Handle all other single return types + logger.debug(f"Task returns unnamed native tuple {return_annotation}") + return {default_output_name(): cast(Type, return_annotation)} + + +def remap_shared_output_descriptions(output_descriptions: Dict[str, str], outputs: Dict[str, Type]) -> Dict[str, str]: + """ + Deals with mixed styles of return value descriptions used in docstrings. If the docstring contains a single entry of return value description, that output description is shared by each output variable. + :param output_descriptions: Dict of output variable names mapping to output description + :param outputs: Interface outputs + :return: Dict of output variable names mapping to shared output description + """ + # no need to remap + if len(output_descriptions) != 1: + return output_descriptions + _, shared_description = next(iter(output_descriptions.items())) + return {k: shared_description for k, _ in outputs.items()} diff --git a/flytekit/flytekit/core/launch_plan.py b/flytekit/flytekit/core/launch_plan.py new file mode 100644 index 0000000000..a96f83b8ce --- /dev/null +++ b/flytekit/flytekit/core/launch_plan.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import typing +from typing import Any, Callable, Dict, List, Optional, Type + +from flytekit.core import workflow as _annotated_workflow +from flytekit.core.context_manager import FlyteContext, FlyteContextManager, FlyteEntities +from flytekit.core.interface import Interface, transform_function_to_interface, transform_inputs_to_parameters +from flytekit.core.promise import create_and_link_node, translate_inputs_to_literals +from flytekit.core.reference_entity import LaunchPlanReference, ReferenceEntity +from flytekit.models import common as _common_models +from flytekit.models import interface as _interface_models +from flytekit.models import literals as _literal_models +from flytekit.models import schedule as _schedule_model +from flytekit.models import security +from flytekit.models.core import workflow as _workflow_model + + +class LaunchPlan(object): + """ + Launch Plans are one of the core constructs of Flyte. Please take a look at the discussion in the + :std:ref:`core concepts ` if you are unfamiliar with them. + + Every workflow is registered with a default launch plan, which is just a launch plan with none of the additional + attributes set - no default values, fixed values, schedules, etc. Assuming you have the following workflow + + .. code-block:: python + + @workflow + def wf(a: int, c: str) -> str: + ... + + Create the default launch plan with + + .. code-block:: python + + LaunchPlan.get_or_create(workflow=my_wf) + + If you specify additional parameters, you'll also have to give the launch plan a unique name. Default and + fixed inputs can be expressed as Python native values like so: + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_launch_plan.py + :start-after: # fixed_and_default_start + :end-before: # fixed_and_default_end + :language: python + :dedent: 4 + + Additionally, a launch plan can be configured to run on a schedule and emit notifications. + + + Please see the relevant Schedule and Notification objects as well. + + To configure the remaining parameters, you'll need to import the relevant model objects as well. + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_launch_plan.py + :start-after: # schedule_start + :end-before: # schedule_end + :language: python + :dedent: 4 + + .. code-block:: python + + from flytekit.models.common import Annotations, AuthRole, Labels, RawOutputDataConfig + + Then use as follows + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_launch_plan.py + :start-after: # auth_role_start + :end-before: # auth_role_end + :language: python + :dedent: 4 + + """ + + # The reason we cache is simply because users may get the default launch plan twice for a single Workflow. We + # don't want to create two defaults, could be confusing. + CACHE: typing.Dict[str, LaunchPlan] = {} + + @staticmethod + def get_default_launch_plan(ctx: FlyteContext, workflow: _annotated_workflow.WorkflowBase) -> LaunchPlan: + """ + Users should probably call the get_or_create function defined below instead. A default launch plan is the one + that will just pick up whatever default values are defined in the workflow function signature (if any) and + use the default auth information supplied during serialization, with no notifications or schedules. + + :param ctx: This is not flytekit.current_context(). This is an internal context object. Users familiar with + flytekit should feel free to use this however. + :param workflow: The workflow to create a launch plan for. + """ + if workflow.name in LaunchPlan.CACHE: + return LaunchPlan.CACHE[workflow.name] + + parameter_map = transform_inputs_to_parameters(ctx, workflow.python_interface) + + lp = LaunchPlan( + name=workflow.name, + workflow=workflow, + parameters=parameter_map, + fixed_inputs=_literal_models.LiteralMap(literals={}), + ) + + # Ensure default parameters are available when using lp.__call__() + default_inputs = { + name: default for name, (type, default) in workflow.python_interface.inputs_with_defaults.items() + } + lp._saved_inputs = default_inputs + + LaunchPlan.CACHE[workflow.name] = lp + return lp + + @classmethod + def create( + cls, + name: str, + workflow: _annotated_workflow.WorkflowBase, + default_inputs: Optional[Dict[str, Any]] = None, + fixed_inputs: Optional[Dict[str, Any]] = None, + schedule: Optional[_schedule_model.Schedule] = None, + notifications: Optional[List[_common_models.Notification]] = None, + labels: Optional[_common_models.Labels] = None, + annotations: Optional[_common_models.Annotations] = None, + raw_output_data_config: Optional[_common_models.RawOutputDataConfig] = None, + max_parallelism: Optional[int] = None, + security_context: Optional[security.SecurityContext] = None, + auth_role: Optional[_common_models.AuthRole] = None, + ) -> LaunchPlan: + ctx = FlyteContextManager.current_context() + default_inputs = default_inputs or {} + fixed_inputs = fixed_inputs or {} + # Default inputs come from two places, the original signature of the workflow function, and the default_inputs + # argument to this function. We'll take the latter as having higher precedence. + wf_signature_parameters = transform_inputs_to_parameters(ctx, workflow.python_interface) + + # Construct a new Interface object with just the default inputs given to get Parameters, maybe there's an + # easier way to do this, think about it later. + temp_inputs = {} + for k, v in default_inputs.items(): + temp_inputs[k] = (workflow.python_interface.inputs[k], v) + temp_interface = Interface(inputs=temp_inputs, outputs={}) # type: ignore + temp_signature = transform_inputs_to_parameters(ctx, temp_interface) + wf_signature_parameters._parameters.update(temp_signature.parameters) + + # These are fixed inputs that cannot change at launch time. If the same argument is also in default inputs, + # it'll be taken out from defaults in the LaunchPlan constructor + fixed_literals = translate_inputs_to_literals( + ctx, + incoming_values=fixed_inputs, + flyte_interface_types=workflow.interface.inputs, + native_types=workflow.python_interface.inputs, + ) + fixed_lm = _literal_models.LiteralMap(literals=fixed_literals) + + if auth_role: + if security_context: + raise ValueError("Use of AuthRole is deprecated. You cannot specify both AuthRole and SecurityContext") + + security_context = security.SecurityContext( + run_as=security.Identity( + iam_role=auth_role.assumable_iam_role, + k8s_service_account=auth_role.kubernetes_service_account, + ), + ) + + lp = cls( + name=name, + workflow=workflow, + parameters=wf_signature_parameters, + fixed_inputs=fixed_lm, + schedule=schedule, + notifications=notifications, + labels=labels, + annotations=annotations, + raw_output_data_config=raw_output_data_config, + max_parallelism=max_parallelism, + security_context=security_context, + ) + + # This is just a convenience - we'll need the fixed inputs LiteralMap for when serializing the Launch Plan out + # to protobuf, but for local execution and such, why not save the original Python native values as well so + # we don't have to reverse it back every time. + default_inputs.update(fixed_inputs) + lp._saved_inputs = default_inputs + + if name in cls.CACHE: + raise AssertionError(f"Launch plan named {name} was already created! Make sure your names are unique.") + cls.CACHE[name] = lp + return lp + + @classmethod + def get_or_create( + cls, + workflow: _annotated_workflow.WorkflowBase, + name: Optional[str] = None, + default_inputs: Optional[Dict[str, Any]] = None, + fixed_inputs: Optional[Dict[str, Any]] = None, + schedule: Optional[_schedule_model.Schedule] = None, + notifications: Optional[List[_common_models.Notification]] = None, + labels: Optional[_common_models.Labels] = None, + annotations: Optional[_common_models.Annotations] = None, + raw_output_data_config: Optional[_common_models.RawOutputDataConfig] = None, + max_parallelism: Optional[int] = None, + security_context: Optional[security.SecurityContext] = None, + auth_role: Optional[_common_models.AuthRole] = None, + ) -> LaunchPlan: + """ + This function offers a friendlier interface for creating launch plans. If the name for the launch plan is not + supplied, this assumes you are looking for the default launch plan for the workflow. If it is specified, it + will be used. If creating the default launch plan, none of the other arguments may be specified. + + The resulting launch plan is also cached and if called again with the same name, the + cached version is returned + + :param security_context: Security context for the execution + :param workflow: The Workflow to create a launch plan for. + :param name: If you supply a name, keep it mind it needs to be unique. That is, project, domain, version, and + this name form a primary key. If you do not supply a name, this function will assume you want the default + launch plan for the given workflow. + :param default_inputs: Default inputs, expressed as Python values. + :param fixed_inputs: Fixed inputs, expressed as Python values. At call time, these cannot be changed. + :param schedule: Optional schedule to run on. + :param notifications: Notifications to send. + :param labels: Optional labels to attach to executions created by this launch plan. + :param annotations: Optional annotations to attach to executions created by this launch plan. + :param raw_output_data_config: Optional location of offloaded data for things like S3, etc. + :param auth_role: Add an auth role if necessary. + :param max_parallelism: Controls the maximum number of tasknodes that can be run in parallel for the entire + workflow. This is useful to achieve fairness. Note: MapTasks are regarded as one unit, and + parallelism/concurrency of MapTasks is independent from this. + """ + if name is None and ( + default_inputs is not None + or fixed_inputs is not None + or schedule is not None + or notifications is not None + or labels is not None + or annotations is not None + or raw_output_data_config is not None + or auth_role is not None + or max_parallelism is not None + or security_context is not None + ): + raise ValueError( + "Only named launchplans can be created that have other properties. Drop the name if you want to create a default launchplan. Default launchplans cannot have any other associations" + ) + + if name is not None and name in LaunchPlan.CACHE: + cached_outputs = vars(LaunchPlan.CACHE[name]) + + notifications = notifications or [] + default_inputs = default_inputs or {} + fixed_inputs = fixed_inputs or {} + default_inputs.update(fixed_inputs) + + if auth_role and not security_context: + security_context = security.SecurityContext( + run_as=security.Identity( + iam_role=auth_role.assumable_iam_role, + k8s_service_account=auth_role.kubernetes_service_account, + ), + ) + + if ( + workflow != cached_outputs["_workflow"] + or schedule != cached_outputs["_schedule"] + or notifications != cached_outputs["_notifications"] + or default_inputs != cached_outputs["_saved_inputs"] + or labels != cached_outputs["_labels"] + or annotations != cached_outputs["_annotations"] + or raw_output_data_config != cached_outputs["_raw_output_data_config"] + or max_parallelism != cached_outputs["_max_parallelism"] + or security_context != cached_outputs["_security_context"] + ): + raise AssertionError("The cached values aren't the same as the current call arguments") + + return LaunchPlan.CACHE[name] + elif name is None and workflow.name in LaunchPlan.CACHE: + return LaunchPlan.CACHE[workflow.name] + + # Otherwise, handle the default launch plan case + if name is None: + ctx = FlyteContext.current_context() + lp = cls.get_default_launch_plan(ctx, workflow) + else: + lp = cls.create( + name, + workflow, + default_inputs, + fixed_inputs, + schedule, + notifications, + labels, + annotations, + raw_output_data_config, + max_parallelism, + auth_role=auth_role, + security_context=security_context, + ) + LaunchPlan.CACHE[name or workflow.name] = lp + return lp + + def __init__( + self, + name: str, + workflow: _annotated_workflow.WorkflowBase, + parameters: _interface_models.ParameterMap, + fixed_inputs: _literal_models.LiteralMap, + schedule: Optional[_schedule_model.Schedule] = None, + notifications: Optional[List[_common_models.Notification]] = None, + labels: Optional[_common_models.Labels] = None, + annotations: Optional[_common_models.Annotations] = None, + raw_output_data_config: Optional[_common_models.RawOutputDataConfig] = None, + max_parallelism: Optional[int] = None, + security_context: Optional[security.SecurityContext] = None, + additional_metadata: Optional[Any] = None, + ): + self._name = name + self._workflow = workflow + # Ensure fixed inputs are not in parameter map + parameters = {k: v for k, v in parameters.parameters.items() if k not in fixed_inputs.literals} + self._parameters = _interface_models.ParameterMap(parameters=parameters) + self._fixed_inputs = fixed_inputs + # See create() for additional information + self._saved_inputs: Dict[str, Any] = {} + + self._schedule = schedule + self._notifications = notifications or [] + self._labels = labels + self._annotations = annotations + self._raw_output_data_config = raw_output_data_config + self._max_parallelism = max_parallelism + self._security_context = security_context + self._additional_metadata = additional_metadata + + FlyteEntities.entities.append(self) + + def clone_with( + self, + name: str, + parameters: Optional[_interface_models.ParameterMap] = None, + fixed_inputs: Optional[_literal_models.LiteralMap] = None, + schedule: Optional[_schedule_model.Schedule] = None, + notifications: Optional[List[_common_models.Notification]] = None, + labels: Optional[_common_models.Labels] = None, + annotations: Optional[_common_models.Annotations] = None, + raw_output_data_config: Optional[_common_models.RawOutputDataConfig] = None, + max_parallelism: Optional[int] = None, + security_context: Optional[security.SecurityContext] = None, + ) -> LaunchPlan: + return LaunchPlan( + name=name, + workflow=self.workflow, + parameters=parameters or self.parameters, + fixed_inputs=fixed_inputs or self.fixed_inputs, + schedule=schedule or self.schedule, + notifications=notifications or self.notifications, + labels=labels or self.labels, + annotations=annotations or self.annotations, + raw_output_data_config=raw_output_data_config or self.raw_output_data_config, + max_parallelism=max_parallelism or self.max_parallelism, + security_context=security_context or self.security_context, + ) + + @property + def python_interface(self) -> Interface: + return self.workflow.python_interface + + @property + def interface(self) -> _interface_models.TypedInterface: + return self.workflow.interface + + @property + def name(self) -> str: + return self._name + + @property + def parameters(self) -> _interface_models.ParameterMap: + return self._parameters + + @property + def fixed_inputs(self) -> _literal_models.LiteralMap: + return self._fixed_inputs + + @property + def workflow(self) -> _annotated_workflow.WorkflowBase: + return self._workflow + + @property + def saved_inputs(self) -> Dict[str, Any]: + # See note in create() + # Since the call-site will typically update the dict returned, and since update updates in place, let's return + # a copy. + # TODO: What issues will there be when we start introducing custom classes as input types? + return self._saved_inputs.copy() + + @property + def schedule(self) -> Optional[_schedule_model.Schedule]: + return self._schedule + + @property + def notifications(self) -> List[_common_models.Notification]: + return self._notifications + + @property + def labels(self) -> Optional[_common_models.Labels]: + return self._labels + + @property + def annotations(self) -> Optional[_common_models.Annotations]: + return self._annotations + + @property + def raw_output_data_config(self) -> Optional[_common_models.RawOutputDataConfig]: + return self._raw_output_data_config + + @property + def max_parallelism(self) -> Optional[int]: + return self._max_parallelism + + @property + def security_context(self) -> Optional[security.SecurityContext]: + return self._security_context + + @property + def additional_metadata(self) -> Optional[Any]: + return self._additional_metadata + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + return self.workflow.construct_node_metadata() + + def __call__(self, *args, **kwargs): + if len(args) > 0: + raise AssertionError("Only Keyword Arguments are supported for launch plan executions") + + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None: + inputs = self.saved_inputs + inputs.update(kwargs) + return create_and_link_node(ctx, entity=self, **inputs) + else: + # Calling a launch plan should just forward the call to the workflow, nothing more. But let's add in the + # saved inputs. + inputs = self.saved_inputs + inputs.update(kwargs) + return self.workflow(*args, **inputs) + + +class ReferenceLaunchPlan(ReferenceEntity, LaunchPlan): + """ + A reference launch plan serves as a pointer to a Launch Plan that already exists on your Flyte installation. This + object will not initiate a network call to Admin, which is why the user is asked to provide the expected interface. + If at registration time the interface provided causes an issue with compilation, an error will be returned. + """ + + def __init__( + self, project: str, domain: str, name: str, version: str, inputs: Dict[str, Type], outputs: Dict[str, Type] + ): + super().__init__(LaunchPlanReference(project, domain, name, version), inputs, outputs) + + +def reference_launch_plan( + project: str, + domain: str, + name: str, + version: str, +) -> Callable[[Callable[..., Any]], ReferenceLaunchPlan]: + """ + A reference launch plan is a pointer to a launch plan that already exists on your Flyte installation. This + object will not initiate a network call to Admin, which is why the user is asked to provide the expected interface + via the function definition. + + If at registration time the interface provided causes an issue with compilation, an error will be returned. + + :param project: Flyte project name of the launch plan + :param domain: Flyte domain name of the launch plan + :param name: launch plan name + :param version: specific version of the launch plan to use + """ + + def wrapper(fn) -> ReferenceLaunchPlan: + interface = transform_function_to_interface(fn) + return ReferenceLaunchPlan(project, domain, name, version, interface.inputs, interface.outputs) + + return wrapper diff --git a/flytekit/flytekit/core/local_cache.py b/flytekit/flytekit/core/local_cache.py new file mode 100644 index 0000000000..7806452c5e --- /dev/null +++ b/flytekit/flytekit/core/local_cache.py @@ -0,0 +1,73 @@ +from typing import Optional + +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" + + +def _recursive_hash_placement(literal: Literal) -> Literal: + # Base case, hash gets passed through always if set + if literal.hash is not None: + return Literal(hash=literal.hash) + elif literal.collection is not None: + literals = [_recursive_hash_placement(lit) for lit in literal.collection.literals] + return Literal(collection=LiteralCollection(literals=literals)) + elif literal.map is not None: + literal_map = {} + for key, literal_value in literal.map.literals.items(): + literal_map[key] = _recursive_hash_placement(literal_value) + return Literal(map=LiteralMap(literal_map)) + else: + return literal + + +def _calculate_cache_key(task_name: str, cache_version: str, input_literal_map: LiteralMap) -> str: + # Traverse the literals and replace the literal with a new literal that only contains the hash + literal_map_overridden = {} + for key, literal in input_literal_map.literals.items(): + literal_map_overridden[key] = _recursive_hash_placement(literal) + + # Generate a stable representation of the underlying protobuf by passing `deterministic=True` to the + # protobuf library. + hashed_inputs = LiteralMap(literal_map_overridden).to_flyte_idl().SerializeToString(deterministic=True) + # Use joblib to hash the string representation of the literal into a fixed length string + return f"{task_name}-{cache_version}-{joblib.hash(hashed_inputs)}" + + +class LocalTaskCache(object): + """ + This class implements a persistent store able to cache the result of local task executions. + """ + + _cache: Cache + _initialized: bool = False + + @staticmethod + def initialize(): + LocalTaskCache._cache = Cache(CACHE_LOCATION) + LocalTaskCache._initialized = True + + @staticmethod + def clear(): + if not LocalTaskCache._initialized: + LocalTaskCache.initialize() + LocalTaskCache._cache.clear() + + @staticmethod + def get(task_name: str, cache_version: str, input_literal_map: LiteralMap) -> Optional[LiteralMap]: + if not LocalTaskCache._initialized: + LocalTaskCache.initialize() + return LocalTaskCache._cache.get(_calculate_cache_key(task_name, cache_version, input_literal_map)) + + @staticmethod + def set(task_name: str, cache_version: str, input_literal_map: LiteralMap, value: LiteralMap) -> None: + if not LocalTaskCache._initialized: + LocalTaskCache.initialize() + LocalTaskCache._cache.set(_calculate_cache_key(task_name, cache_version, input_literal_map), value) diff --git a/flytekit/flytekit/core/local_fsspec.py b/flytekit/flytekit/core/local_fsspec.py new file mode 100644 index 0000000000..b452b3006e --- /dev/null +++ b/flytekit/flytekit/core/local_fsspec.py @@ -0,0 +1,27 @@ +""" +====================================== +:mod:`flytekit.core.local_fsspec` +====================================== + +.. currentmodule:: flytekit.core.local_fsspec + + +.. autosummary:: + :toctree: generated/ + :template: custom.rst + :nosignatures: + + FlyteLocalFileSystem + +""" +import os + +from fsspec.implementations.local import LocalFileSystem + + +class FlyteLocalFileSystem(LocalFileSystem): # noqa + """ + This class doesn't do anything except override the separator so that it works on windows + """ + + sep = os.sep diff --git a/flytekit/flytekit/core/map_task.py b/flytekit/flytekit/core/map_task.py new file mode 100644 index 0000000000..aac31a1ee9 --- /dev/null +++ b/flytekit/flytekit/core/map_task.py @@ -0,0 +1,415 @@ +""" +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 math +import os +import typing +from contextlib import contextmanager +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, Task, TaskResolverMixin +from flytekit.core.constants import CONTAINER_ARRAY_TASK +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, PythonInstanceTask +from flytekit.core.tracker import TrackedInstance +from flytekit.core.utils import timeit +from flytekit.exceptions import scopes as exception_scopes +from flytekit.loggers import logger +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. + """ + + def __init__( + self, + python_function_task: typing.Union[PythonFunctionTask, PythonInstanceTask, 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 + :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 + :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`. + """ + 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): + if isinstance(actual_task, PythonInstanceTask): + pass + else: + raise ValueError("Map tasks can only compose of PythonFuncton and PythonInstanceTasks currently") + + n_outputs = len(actual_task.python_interface.outputs.keys()) + if n_outputs > 1: + raise ValueError("Map tasks only accept python function tasks with 0 or 1 outputs") + + 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()) + + # Transform the interface to List[Optional[T]] in case `min_success_ratio` is set + output_as_list_of_optionals = min_success_ratio is not None and min_success_ratio != 1 and n_outputs == 1 + collection_interface = transform_interface_to_list_interface( + actual_task.python_interface, self._bound_inputs, output_as_list_of_optionals + ) + + self._run_task: typing.Union[PythonFunctionTask, PythonInstanceTask] = actual_task # type: ignore + if isinstance(actual_task, PythonInstanceTask): + mod = actual_task.task_type + f = actual_task.lhs + else: + _, mod, f, _ = tracker.extract_task_module(typing.cast(PythonFunctionTask, actual_task).task_function) + sorted_bounded_inputs = ",".join(sorted(self._bound_inputs)) + h = hashlib.md5(sorted_bounded_inputs.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, + task_type=CONTAINER_ARRAY_TASK, + task_config=None, + task_type_version=1, + **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", + "{{.input}}", + "--output-prefix", + "{{.outputPrefix}}", + "--raw-output-data-prefix", + "{{.rawOutputDataPrefix}}", + "--checkpoint-path", + "{{.checkpointOutputPrefix}}", + "--prev-checkpoint", + "{{.prevCheckpointPrefix}}", + "--resolver", + mt.name(), + "--", + *mt.loader_args(settings, self), + ] + + if self._cmd_prefix: + return self._cmd_prefix + container_args + return container_args + + def set_command_prefix(self, cmd: typing.Optional[typing.List[str]]): + self._cmd_prefix = cmd + + @contextmanager + def prepare_target(self): + """ + TODO: why do we do this? + Alters the underlying run_task command to modify it for map task execution and then resets it after. + """ + self._run_task.set_command_fn(self.get_command) + try: + yield + finally: + self._run_task.reset_command_fn() + + def get_container(self, settings: SerializationSettings) -> Container: + with self.prepare_target(): + return self._run_task.get_container(settings) + + def get_k8s_pod(self, settings: SerializationSettings) -> K8sPod: + with self.prepare_target(): + return self._run_task.get_k8s_pod(settings) + + def get_sql(self, settings: SerializationSettings) -> Sql: + with self.prepare_target(): + return self._run_task.get_sql(settings) + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + return ArrayJob(parallelism=self._max_concurrency, min_success_ratio=self._min_success_ratio).to_dict() + + def get_config(self, settings: SerializationSettings) -> Optional[Dict[str, str]]: + return self._run_task.get_config(settings) + + @property + def run_task(self) -> typing.Union[PythonFunctionTask, PythonInstanceTask]: + 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: + return self._execute_map_task(ctx, **kwargs) + + return self._raw_execute(**kwargs) + + @staticmethod + def _compute_array_job_index() -> int: + """ + Computes the absolute index of the current array job. This is determined by summing the compute-environment-specific + environment variable and the offset (if one's set). The offset will be set and used when the user request that the + job runs in a number of slots less than the size of the input. + """ + return int(os.environ.get("BATCH_JOB_ARRAY_INDEX_OFFSET", "0")) + int( + os.environ.get(os.environ.get("BATCH_JOB_ARRAY_INDEX_VAR_NAME", "0"), "0") + ) + + @property + def _outputs_interface(self) -> Dict[Any, Variable]: + """ + We override this method from PythonTask because the dispatch_execute method uses this + interface to construct outputs. Each instance of an container_array task will however produce outputs + according to the underlying run_task interface and the array plugin handler will actually create a collection + from these individual outputs as the final output value. + """ + + ctx = FlyteContextManager.current_context() + if ctx.execution_state and ctx.execution_state.is_local_execution(): + # In workflow execution mode we actually need to use the parent (mapper) task output interface. + return self.interface.outputs + return self._run_task.interface.outputs + + 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 + according to the underlying run_task interface and the array plugin handler will actually create a collection + from these individual outputs as the final output value. + """ + ctx = FlyteContextManager.current_context() + if ctx.execution_state and ctx.execution_state.is_local_execution(): + # In workflow execution mode we actually need to use the parent (mapper) task output interface. + return self._python_interface.outputs[k] + return self._run_task._python_interface.outputs[k] + + def _execute_map_task(self, _: FlyteContext, **kwargs) -> Any: + """ + This is called during ExecutionState.Mode.TASK_EXECUTION executions, that is executions orchestrated by the + Flyte platform. Individual instances of the map task, aka array task jobs are passed the full set of inputs but + only produce a single output based on the map task (array task) instance. The array plugin handler will actually + create a collection from these individual outputs as the final map task output value. + """ + task_index = self._compute_array_job_index() + map_task_inputs = {} + for k in self.interface.inputs.keys(): + 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: + """ + This is called during locally run executions. Unlike array task execution on the Flyte platform, _raw_execute + produces the full output collection. + """ + outputs_expected = True + if not self.interface.outputs: + outputs_expected = False + outputs = [] + + mapped_tasks_count = 0 + if self._run_task.interface.inputs.items(): + for k in self._run_task.interface.inputs.keys(): + v = kwargs[k] + if isinstance(v, list) and k not in self.bound_inputs: + mapped_tasks_count = len(v) + break + + failed_count = 0 + min_successes = mapped_tasks_count + if self._min_success_ratio: + min_successes = math.ceil(min_successes * self._min_success_ratio) + + for i in range(mapped_tasks_count): + single_instance_inputs = {} + for k in self.interface.inputs.keys(): + 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] + try: + o = exception_scopes.user_entry_point(self._run_task.execute)(**single_instance_inputs) + if outputs_expected: + outputs.append(o) + except Exception as exc: + outputs.append(None) + failed_count += 1 + if mapped_tasks_count - failed_count < min_successes: + logger.error("The number of successful tasks is lower than the minimum ratio") + raise exc + + return outputs + + +def map_task( + task_function: typing.Union[PythonFunctionTask, PythonInstanceTask, 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`. + + Invoke a map task with arguments using the :py:class:`list` version of the expected input. + + Usage: + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_map_task.py + :start-after: # test_map_task_start + :end-before: # test_map_task_end + :language: python + :dedent: 4 + + At run time, the underlying map task will be run for every value in the input collection. Attributes + such as :py:class:`flytekit.TaskMetadata` and ``with_overrides`` are applied to individual instances + of the mapped task. + + **Map Task Plugins** + + There are two plugins to run maptasks that ship as part of flyteplugins: + + 1. K8s Array + 2. `AWS batch `_ + + Enabling a plugin is controlled in the plugin configuration at `values-sandbox.yaml `_. + + **K8s Array** + + By default, the map task uses the ``K8s Array`` plugin. It executes array tasks by launching a pod for every instance in the array. It’s simple to use, has a straightforward implementation, and works out of the box. + + **AWS batch** + + Learn more about ``AWS batch`` setup configuration `here `_. + + A custom plugin can also be implemented to handle the task type. + + :param task_function: 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. If the size of the input exceeds the concurrency value, then multiple batches will be run serially until + all inputs are processed. If left unspecified, this means unbounded concurrency. + :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. + + """ + 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(sorted(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/flytekit/core/mock_stats.py b/flytekit/flytekit/core/mock_stats.py new file mode 100644 index 0000000000..dedce6ae7c --- /dev/null +++ b/flytekit/flytekit/core/mock_stats.py @@ -0,0 +1,64 @@ +import datetime as _datetime + +from flytekit.loggers import logger + + +class MockStats(object): + def __init__(self, scope="", tags=None): + """ + Initializes a new mock stats object + :param Text scope: + :param dict[Text, Text] tags: + """ + self.scope = scope + self.tags = tags + self._records = {} + self._records_tags = {} + + def incr(self, metric, count=1, tags=None, **kwargs): + full_name = self.scope + "." + metric + self._records[full_name] = self._records.get(full_name, 0) + count + self._records_tags[full_name] = tags or {} + + def decr(self, metric, count=1, tags=None, **kwargs): + full_name = self.scope + "." + metric + self._records[full_name] = self._records.get(full_name, 0) - count + self._records_tags[full_name] = tags or {} + + def timing(self, metric): + logger.warning("mock timing isn't implemented yet.") + + def timer(self, metric, tags=None, **kwargs): + return _Timer(self, metric, tags=tags or {}) + + def gauge(self, metric, value, tags=None, **kwargs): + full_name = self.scope + "." + metric + self._records[full_name] = value + self._records_tags[full_name] = tags or {} + + def current_value(self, metric): + full_name = self.scope + "." + metric + return self._records.get(full_name, None) + + def current_tags(self, metric): + full_name = self.scope + "." + metric + return self._records_tags.get(full_name, None) + + +class _Timer(object): + def __init__(self, mock_stats, metric, tags): + """ + :param MockStats mock_stats: + :param Text metric: + """ + self._mock_stats = mock_stats + self._metric = metric + self._timer = None + self._tags = tags + + def __enter__(self): + self._timer = _datetime.datetime.utcnow() + + def __exit__(self, exc_type, exc_val, exc_tb): + self._mock_stats.gauge(self._metric, _datetime.datetime.utcnow() - self._timer, tags=self._tags) + self._timer = None diff --git a/flytekit/flytekit/core/node.py b/flytekit/flytekit/core/node.py new file mode 100644 index 0000000000..f5a3db4afa --- /dev/null +++ b/flytekit/flytekit/core/node.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import datetime +import typing +from typing import Any, List + +from flyteidl.core import tasks_pb2 + +from flytekit.core.resources import Resources, convert_resources_to_resource_model +from flytekit.core.utils import _dnsify +from flytekit.loggers import logger +from flytekit.models import literals as _literal_models +from flytekit.models.core import workflow as _workflow_model +from flytekit.models.task import Resources as _resources_model + + +def assert_not_promise(v: Any, location: str): + """ + This function will raise an exception if the value is a promise. This should be used to ensure that we don't + accidentally use a promise in a place where we don't support it. + """ + from flytekit.core.promise import Promise + + if isinstance(v, Promise): + raise AssertionError(f"Cannot use a promise in the {location} Value: {v}") + + +def assert_no_promises_in_resources(resources: _resources_model): + """ + This function will raise an exception if any of the resources have promises in them. This is because we don't + support promises in resources / runtime overriding of resources through input values. + """ + if resources is None: + return + if resources.requests is not None: + for r in resources.requests: + assert_not_promise(r.value, "resources.requests") + if resources.limits is not None: + for r in resources.limits: + assert_not_promise(r.value, "resources.limits") + + +class Node(object): + """ + This class will hold all the things necessary to make an SdkNode but we won't make one until we know things like + ID, which from the registration step + """ + + def __init__( + self, + id: str, + metadata: _workflow_model.NodeMetadata, + bindings: List[_literal_models.Binding], + upstream_nodes: List[Node], + flyte_entity: Any, + ): + if id is None: + raise ValueError("Illegal construction of node, without a Node ID") + self._id = _dnsify(id) + self._metadata = metadata + self._bindings = bindings + self._upstream_nodes = upstream_nodes + self._flyte_entity = flyte_entity + self._aliases: _workflow_model.Alias = None + self._outputs = None + self._resources: typing.Optional[_resources_model] = None + self._extended_resources: typing.Optional[tasks_pb2.ExtendedResources] = None + + def runs_before(self, other: Node): + """ + This is typically something we shouldn't do. This modifies an attribute of the other instance rather than + self. But it's done so only because we wanted this English function to be the same as the shift function. + That is, calling node_1.runs_before(node_2) and node_1 >> node_2 are the same. The shift operator going the + other direction is not implemented to further avoid confusion. Right shift was picked rather than left shift + because that's what most users are familiar with. + """ + if self not in other._upstream_nodes: + other._upstream_nodes.append(self) + + def __rshift__(self, other: Node): + self.runs_before(other) + return other + + @property + def name(self) -> str: + return self._id + + @property + def outputs(self): + if self._outputs is None: + raise AssertionError("Cannot use outputs with all Nodes, node must've been created from create_node()") + return self._outputs + + @property + def id(self) -> str: + return self._id + + @property + def bindings(self) -> List[_literal_models.Binding]: + return self._bindings + + @property + def upstream_nodes(self) -> List[Node]: + return self._upstream_nodes + + @property + def flyte_entity(self) -> Any: + return self._flyte_entity + + @property + def run_entity(self) -> Any: + from flytekit.core.array_node_map_task import ArrayNodeMapTask + from flytekit.core.map_task import MapPythonTask + + if isinstance(self.flyte_entity, MapPythonTask): + return self.flyte_entity.run_task + if isinstance(self.flyte_entity, ArrayNodeMapTask): + return self.flyte_entity.python_function_task + return self.flyte_entity + + @property + def metadata(self) -> _workflow_model.NodeMetadata: + return self._metadata + + def with_overrides(self, *args, **kwargs): + if "node_name" in kwargs: + # Convert the node name into a DNS-compliant. + # https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names + v = kwargs["node_name"] + assert_not_promise(v, "node_name") + self._id = _dnsify(v) + + if "aliases" in kwargs: + alias_dict = kwargs["aliases"] + if not isinstance(alias_dict, dict): + raise AssertionError("Aliases should be specified as dict[str, str]") + self._aliases = [] + for k, v in alias_dict.items(): + self._aliases.append(_workflow_model.Alias(var=k, alias=v)) + + if "requests" in kwargs or "limits" in kwargs: + requests = kwargs.get("requests") + if requests and not isinstance(requests, Resources): + raise AssertionError("requests should be specified as flytekit.Resources") + limits = kwargs.get("limits") + if limits and not isinstance(limits, Resources): + raise AssertionError("limits should be specified as flytekit.Resources") + + if not limits: + logger.warning( + ( + f"Requests overridden on node {self.id} ({self.metadata.short_string()}) without specifying limits. " + "Requests are clamped to original limits." + ) + ) + + resources = convert_resources_to_resource_model(requests=requests, limits=limits) + assert_no_promises_in_resources(resources) + self._resources = resources + + if "timeout" in kwargs: + timeout = kwargs["timeout"] + if timeout is None: + self._metadata._timeout = datetime.timedelta() + elif isinstance(timeout, int): + self._metadata._timeout = datetime.timedelta(seconds=timeout) + elif isinstance(timeout, datetime.timedelta): + self._metadata._timeout = timeout + else: + raise ValueError("timeout should be duration represented as either a datetime.timedelta or int seconds") + if "retries" in kwargs: + retries = kwargs["retries"] + assert_not_promise(retries, "retries") + self._metadata._retries = ( + _literal_models.RetryStrategy(0) if retries is None else _literal_models.RetryStrategy(retries) + ) + + if "interruptible" in kwargs: + v = kwargs["interruptible"] + assert_not_promise(v, "interruptible") + self._metadata._interruptible = kwargs["interruptible"] + + if "name" in kwargs: + self._metadata._name = kwargs["name"] + + if "task_config" in kwargs: + logger.warning("This override is beta. We may want to revisit this in the future.") + new_task_config = kwargs["task_config"] + if not isinstance(new_task_config, type(self.run_entity._task_config)): + raise ValueError("can't change the type of the task config") + self.run_entity._task_config = new_task_config + + if "container_image" in kwargs: + v = kwargs["container_image"] + assert_not_promise(v, "container_image") + self.run_entity._container_image = v + + if "accelerator" in kwargs: + v = kwargs["accelerator"] + assert_not_promise(v, "accelerator") + self._extended_resources = tasks_pb2.ExtendedResources(gpu_accelerator=v.to_flyte_idl()) + + if "cache" in kwargs: + v = kwargs["cache"] + assert_not_promise(v, "cache") + self._metadata._cacheable = kwargs["cache"] + + if "cache_version" in kwargs: + v = kwargs["cache_version"] + assert_not_promise(v, "cache_version") + self._metadata._cache_version = kwargs["cache_version"] + + if "cache_serialize" in kwargs: + v = kwargs["cache_serialize"] + assert_not_promise(v, "cache_serialize") + self._metadata._cache_serializable = kwargs["cache_serialize"] + + return self + + +def _convert_resource_overrides( + resources: typing.Optional[Resources], resource_name: str +) -> typing.List[_resources_model.ResourceEntry]: + if resources is None: + return [] + + resource_entries = [] + if resources.cpu is not None: + resource_entries.append(_resources_model.ResourceEntry(_resources_model.ResourceName.CPU, resources.cpu)) + + if resources.mem is not None: + resource_entries.append(_resources_model.ResourceEntry(_resources_model.ResourceName.MEMORY, resources.mem)) + + if resources.gpu is not None: + resource_entries.append(_resources_model.ResourceEntry(_resources_model.ResourceName.GPU, resources.gpu)) + + if resources.ephemeral_storage is not None: + resource_entries.append( + _resources_model.ResourceEntry( + _resources_model.ResourceName.EPHEMERAL_STORAGE, + resources.ephemeral_storage, + ) + ) + + return resource_entries diff --git a/flytekit/flytekit/core/node_creation.py b/flytekit/flytekit/core/node_creation.py new file mode 100644 index 0000000000..705188c348 --- /dev/null +++ b/flytekit/flytekit/core/node_creation.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Union + +from flytekit.core.base_task import PythonTask +from flytekit.core.context_manager import BranchEvalMode, FlyteContext +from flytekit.core.launch_plan import LaunchPlan +from flytekit.core.node import Node +from flytekit.core.promise import VoidPromise +from flytekit.core.workflow import WorkflowBase +from flytekit.exceptions import user as _user_exceptions +from flytekit.loggers import logger + +if TYPE_CHECKING: + from flytekit.remote.remote_callable import RemoteEntity + + +# This file exists instead of moving to node.py because it needs Task/Workflow/LaunchPlan and those depend on Node + + +def create_node( + entity: Union[PythonTask, LaunchPlan, WorkflowBase, RemoteEntity], *args, **kwargs +) -> Union[Node, VoidPromise]: + """ + This is the function you want to call if you need to specify dependencies between tasks that don't consume and/or + don't produce outputs. For example, if you have t1() and t2(), both of which do not take in nor produce any + outputs, how do you specify that t2 should run before t1? :: + + t1_node = create_node(t1) + t2_node = create_node(t2) + + t2_node.runs_before(t1_node) + # OR + t2_node >> t1_node + + This works for tasks that take inputs as well, say a ``t3(in1: int)`` :: + + t3_node = create_node(t3, in1=some_int) # basically calling t3(in1=some_int) + + You can still use this method to handle setting certain overrides :: + + t3_node = create_node(t3, in1=some_int).with_overrides(...) + + Outputs, if there are any, will be accessible. A `t4() -> (int, str)` :: + + t4_node = create_node(t4) + + In compilation node.o0 has the promise. :: + t5(in1=t4_node.o0) + + If t1 produces only one output, note that in local execution, you still get a wrapper object that + needs to be dereferenced by the output name. :: + + t1_node = create_node(t1) + t2(t1_node.o0) + + """ + from flytekit.remote.remote_callable import RemoteEntity + + if len(args) > 0: + raise _user_exceptions.FlyteAssertion( + f"Only keyword args are supported to pass inputs to workflows and tasks." + f"Aborting execution as detected {len(args)} positional args {args}" + ) + + if ( + not isinstance(entity, PythonTask) + and not isinstance(entity, WorkflowBase) + and not isinstance(entity, LaunchPlan) + and not isinstance(entity, RemoteEntity) + ): + raise AssertionError(f"Should be a callable Flyte entity (either local or fetched) but is {type(entity)}") + + # This function is only called from inside workflows and dynamic tasks. + # That means there are two scenarios we need to take care of, compilation and local workflow execution. + + # When compiling, calling the entity will create a node. + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None and ctx.compilation_state.mode == 1: + outputs = entity(**kwargs) + # This is always the output of create_and_link_node which returns create_task_output, which can be + # VoidPromise, Promise, or our custom namedtuple of Promises. + node = ctx.compilation_state.nodes[-1] + + # In addition to storing the outputs on the object itself, we also want to set them in a map. When used by + # the imperative workflow patterns, users will probably find themselves doing things like + # n = create_node(...) # then + # output_name = "o0" + # n.outputs[output_name] # rather than + # n.o0 + # That is, they'll likely have the name of the output stored as a string variable, and dicts provide cleaner + # access than getattr + node._outputs = {} + + # If a VoidPromise, just return the node. + if isinstance(outputs, VoidPromise): + return node + + # If a Promise or custom namedtuple of Promises, we need to attach each output as an attribute to the node. + # todo: fix the noqas below somehow... can't add abstract property to RemoteEntity because it has to come + # before the model Template classes in FlyteTask/Workflow/LaunchPlan + if entity.interface.outputs: # noqa + if isinstance(outputs, tuple): + for output_name in entity.interface.outputs.keys(): # noqa + attr = getattr(outputs, output_name) + if attr is None: + raise _user_exceptions.FlyteAssertion( + f"Output {output_name} in outputs when calling {entity.name} is empty {attr}." + ) + if hasattr(node, output_name): + raise _user_exceptions.FlyteAssertion( + f"Node {node} already has attribute {output_name}, change the name of output." + ) + setattr(node, output_name, attr) + node.outputs[output_name] = attr + else: + output_names = [k for k in entity.interface.outputs.keys()] # noqa + if len(output_names) != 1: + raise _user_exceptions.FlyteAssertion(f"Output of length 1 expected but {len(output_names)} found") + + if hasattr(node, output_names[0]): + raise _user_exceptions.FlyteAssertion( + f"Node {node} already has attribute {output_names[0]}, change the name of output." + ) + + setattr(node, output_names[0], outputs) # This should be a singular Promise + node.outputs[output_names[0]] = outputs + + return node + + # Handling local execution + # Note: execution state is set to TASK_EXECUTION when running dynamic task locally + # https://github.com/flyteorg/flytekit/blob/0815345faf0fae5dc26746a43d4bda4cc2cdf830/flytekit/core/python_function_task.py#L262 + elif ctx.execution_state and ctx.execution_state.is_local_execution(): + if isinstance(entity, RemoteEntity): + raise AssertionError(f"Remote entities are not yet runnable locally {entity.name}") + + if ctx.execution_state.branch_eval_mode == BranchEvalMode.BRANCH_SKIPPED: + logger.warning(f"Manual node creation cannot be used in branch logic {entity.name}") + raise Exception("Being more restrictive for now and disallowing manual node creation in branch logic") + + # This the output of __call__ under local execute conditions which means this is the output of local_execute + # which means this is the output of create_task_output with Promises containing values (or a VoidPromise) + results = entity(**kwargs) + + # If it's a VoidPromise, let's just return it, it shouldn't get used anywhere and if it does, we want an error + # The reason we return it if it's a tuple is to handle the case where the task returns a typing.NamedTuple. + # In that case, it's already a tuple and we don't need to further tupletize. + if isinstance(results, VoidPromise) or isinstance(results, tuple): + return results # type: ignore + + output_names = entity.python_interface.output_names # type: ignore + + if not output_names: + raise Exception(f"Non-VoidPromise received {results} but interface for {entity.name} doesn't have outputs") + + if len(output_names) == 1: + # See explanation above for why we still tupletize a single element. + return entity.python_interface.output_tuple(results) # type: ignore + + return entity.python_interface.output_tuple(*results) # type: ignore + + else: + raise Exception(f"Cannot use explicit run to call Flyte entities {entity.name}") diff --git a/flytekit/flytekit/core/notification.py b/flytekit/flytekit/core/notification.py new file mode 100644 index 0000000000..cecfe43367 --- /dev/null +++ b/flytekit/flytekit/core/notification.py @@ -0,0 +1,115 @@ +""" +Notifications are primarily used when defining Launch Plans (also can be used when launching executions) and will trigger +the Flyte platform to send emails when a workflow run reaches certain stages (fails or succeeds, etc.). + +.. note:: + + Notifications require some setup and configuration on the Flyte platform side. Please contact your Flyte platform + admins to get this feature enabled. See :std:ref:`cookbook:setting up workflow notifications` + +Each notification type takes a list of :py:class:`flytekit.models.core.execution.WorkflowExecutionPhase` and a list of +emails. Even though there are different notification classes in this module, they all just send email. The differentiation +offers semantic meaning to the end-user but do not functionally behave differently. Successful integration with Slack +and Pagerduty is incumbent on those email API being set-up correctly. + +.. autoclass:: flytekit.core.notification.Notification + +""" +from typing import List + +from flytekit.models import common as _common_model +from flytekit.models.core import execution as _execution_model + + +# Duplicates flytekit.common.notifications.Notification to avoid using the ExtendedSdkType metaclass. +class Notification(_common_model.Notification): + VALID_PHASES = { + _execution_model.WorkflowExecutionPhase.ABORTED, + _execution_model.WorkflowExecutionPhase.FAILED, + _execution_model.WorkflowExecutionPhase.SUCCEEDED, + _execution_model.WorkflowExecutionPhase.TIMED_OUT, + } + + def __init__( + self, + phases: List[int], + email: _common_model.EmailNotification = None, + pager_duty: _common_model.PagerDutyNotification = None, + slack: _common_model.SlackNotification = None, + ): + """ + :param list[int] phases: A required list of phases for which to fire the event. Events can only be fired for + terminal phases. Phases should be as defined in: flytekit.models.core.execution.WorkflowExecutionPhase + """ + self._validate_phases(phases) + super(Notification, self).__init__(phases, email=email, pager_duty=pager_duty, slack=slack) + + def _validate_phases(self, phases: List[int]): + """ + :param list[int] phases: + """ + if len(phases) == 0: + raise AssertionError("You must specify at least one phase for a notification.") + for phase in phases: + if phase not in self.VALID_PHASES: + raise AssertionError(f"Invalid phase: {phase}. only terminal states are permitted for notifications") + + +class PagerDuty(Notification): + """ + This notification should be used when sending emails to the PagerDuty service. + + .. code-block:: python + + from flytekit.models.core.execution import WorkflowExecutionPhase + + PagerDuty(phases=[WorkflowExecutionPhase.SUCCEEDED], recipients_email=["my-team@email.com"]) + """ + + def __init__(self, phases: List[int], recipients_email: List[str]): + """ + :param list[int] phases: A required list of phases for which to fire the event. Events can only be fired for + terminal phases. Phases should be as defined in: flytekit.models.core.execution.WorkflowExecutionPhase + :param list[str] recipients_email: A required non-empty list of recipients for the notification. + """ + super(PagerDuty, self).__init__(phases, pager_duty=_common_model.PagerDutyNotification(recipients_email)) + + +class Email(Notification): + """ + This notification should be used when sending regular emails to people. + + .. code-block:: python + + from flytekit.models.core.execution import WorkflowExecutionPhase + + Email(phases=[WorkflowExecutionPhase.SUCCEEDED], recipients_email=["my-team@email.com"]) + """ + + def __init__(self, phases: List[int], recipients_email: List[str]): + """ + :param list[int] phases: A required list of phases for which to fire the event. Events can only be fired for + terminal phases. Phases should be as defined in: :py:class:`flytekit.models.core.execution.WorkflowExecutionPhase` + :param list[str] recipients_email: A required non-empty list of recipients for the notification. + """ + super(Email, self).__init__(phases, email=_common_model.EmailNotification(recipients_email)) + + +class Slack(Notification): + """ + This notification should be used when sending emails to the Slack. + + .. code-block:: python + + from flytekit.models.core.execution import WorkflowExecutionPhase + + Slack(phases=[WorkflowExecutionPhase.SUCCEEDED], recipients_email=["my-team@email.com"]) + """ + + def __init__(self, phases: List[int], recipients_email: List[str]): + """ + :param list[int] phases: A required list of phases for which to fire the event. Events can only be fired for + terminal phases. Phases should be as defined in: flytekit.models.core.execution.WorkflowExecutionPhase + :param list[str] recipients_email: A required non-empty list of recipients for the notification. + """ + super(Slack, self).__init__(phases, slack=_common_model.SlackNotification(recipients_email)) diff --git a/flytekit/flytekit/core/pod_template.py b/flytekit/flytekit/core/pod_template.py new file mode 100644 index 0000000000..98ba92af36 --- /dev/null +++ b/flytekit/flytekit/core/pod_template.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +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(init=True, repr=True, eq=True, frozen=False) +class PodTemplate(object): + """Custom PodTemplate specification for a Task.""" + + 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/flytekit/core/promise.py b/flytekit/flytekit/core/promise.py new file mode 100644 index 0000000000..f15923ab5f --- /dev/null +++ b/flytekit/flytekit/core/promise.py @@ -0,0 +1,1219 @@ +from __future__ import annotations + +import collections +import inspect +from copy import deepcopy +from enum import Enum +from typing import Any, Coroutine, Dict, List, Optional, Set, Tuple, Union, cast + +from google.protobuf import struct_pb2 as _struct +from typing_extensions import Protocol, get_args + +from flytekit.core import constants as _common_constants +from flytekit.core import context_manager as _flyte_context +from flytekit.core import interface as flyte_interface +from flytekit.core import type_engine +from flytekit.core.context_manager import ( + BranchEvalMode, + ExecutionParameters, + ExecutionState, + FlyteContext, + FlyteContextManager, +) +from flytekit.core.interface import Interface +from flytekit.core.node import Node +from flytekit.core.type_engine import DictTransformer, ListTransformer, TypeEngine, TypeTransformerFailedError +from flytekit.exceptions import user as _user_exceptions +from flytekit.exceptions.user import FlytePromiseAttributeResolveException +from flytekit.loggers import logger +from flytekit.models import interface as _interface_models +from flytekit.models import literals as _literals_models +from flytekit.models import types as _type_models +from flytekit.models import types as type_models +from flytekit.models.core import workflow as _workflow_model +from flytekit.models.literals import Primitive +from flytekit.models.types import SimpleType + + +def translate_inputs_to_literals( + ctx: FlyteContext, + incoming_values: Dict[str, Any], + flyte_interface_types: Dict[str, _interface_models.Variable], + native_types: Dict[str, type], +) -> Dict[str, _literals_models.Literal]: + """ + The point of this function is to extract out Literals from a collection of either Python native values (which would + be converted into Flyte literals) or Promises (the literals in which would just get extracted). + + When calling a task inside a workflow, a user might do something like this. + + def my_wf(in1: int) -> int: + a = task_1(in1=in1) + b = task_2(in1=5, in2=a) + return b + + If this is the case, when task_2 is called in local workflow execution, we'll need to translate the Python native + literal 5 to a Flyte literal. + + More interesting is this: + + def my_wf(in1: int, in2: int) -> int: + a = task_1(in1=in1) + b = task_2(in1=5, in2=[a, in2]) + return b + + Here, in task_2, during execution we'd have a list of Promises. We have to make sure to give task2 a Flyte + LiteralCollection (Flyte's name for list), not a Python list of Flyte literals. + + This helper function is used both when sorting out inputs to a task, as well as outputs of a function. + + :param ctx: Context needed in case a non-primitive literal needs to be translated to a Flyte literal (like a file) + :param incoming_values: This is a map of your task's input or wf's output kwargs basically + :param flyte_interface_types: One side of an :py:class:`flytekit.models.interface.TypedInterface` basically. + :param native_types: Map to native Python type. + """ + if incoming_values is None: + raise ValueError("Incoming values cannot be None, must be a dict") + + result = {} # So as to not overwrite the input_kwargs + for k, v in incoming_values.items(): + if k not in flyte_interface_types: + raise ValueError(f"Received unexpected keyword argument {k}") + var = flyte_interface_types[k] + t = native_types[k] + try: + if type(v) is Promise: + v = resolve_attr_path_in_promise(v) + result[k] = TypeEngine.to_literal(ctx, v, t, var.type) + except TypeTransformerFailedError as exc: + raise TypeTransformerFailedError(f"Failed argument '{k}': {exc}") from exc + + return result + + +def resolve_attr_path_in_promise(p: Promise) -> Promise: + """ + resolve_attr_path_in_promise resolves the attribute path in a promise and returns a new promise with the resolved value + This is for local execution only. The remote execution will be resolved in flytepropeller. + """ + + curr_val = p.val + + used = 0 + + for attr in p.attr_path: + # If current value is Flyte literal collection (list) or map (dictionary), use [] to resolve + if ( + type(curr_val.value) is _literals_models.LiteralMap + or type(curr_val.value) is _literals_models.LiteralCollection + ): + if type(attr) == str and attr not in curr_val.value.literals: + raise FlytePromiseAttributeResolveException( + f"Failed to resolve attribute path {p.attr_path} in promise {p}," + f" attribute {attr} not found in {curr_val.value.literals.keys()}" + ) + + if type(attr) == int and attr >= len(curr_val.value.literals): + raise FlytePromiseAttributeResolveException( + f"Failed to resolve attribute path {p.attr_path} in promise {p}," + f" index {attr} out of range {len(curr_val.value.literals)}" + ) + + curr_val = curr_val.value.literals[attr] + used += 1 + # Scalar is always the leaf. There can't be a collection or map in a scalar. + if type(curr_val.value) is _literals_models.Scalar: + break + + # If the current value is a dataclass, resolve the dataclass with the remaining path + if type(curr_val.value) is _literals_models.Scalar and type(curr_val.value.value) is _struct.Struct: + st = curr_val.value.value + new_st = resolve_attr_path_in_pb_struct(st, attr_path=p.attr_path[used:]) + literal_type = TypeEngine.to_literal_type(type(new_st)) + # Reconstruct the resolved result to flyte literal (because the resolved result might not be struct) + curr_val = TypeEngine.to_literal(FlyteContextManager.current_context(), new_st, type(new_st), literal_type) + + p._val = curr_val + return p + + +def resolve_attr_path_in_pb_struct(st: _struct.Struct, attr_path: List[Union[str, int]]) -> _struct.Struct: + curr_val = st + for attr in attr_path: + if attr not in curr_val: + raise FlytePromiseAttributeResolveException( + f"Failed to resolve attribute path {attr_path} in struct {curr_val}, attribute {attr} not found" + ) + curr_val = curr_val[attr] + return curr_val + + +def get_primitive_val(prim: Primitive) -> Any: + for value in [ + prim.integer, + prim.float_value, + prim.string_value, + prim.boolean, + prim.datetime, + prim.duration, + ]: + if value is not None: + return value + + +class ConjunctionOps(Enum): + AND = "and" + OR = "or" + + +class ComparisonOps(Enum): + EQ = "==" + NE = "!=" + GT = ">" + GE = ">=" + LT = "<" + LE = "<=" + + +_comparators = { + ComparisonOps.EQ: lambda x, y: x == y, + ComparisonOps.NE: lambda x, y: x != y, + ComparisonOps.GT: lambda x, y: x > y, + ComparisonOps.GE: lambda x, y: x >= y, + ComparisonOps.LT: lambda x, y: x < y, + ComparisonOps.LE: lambda x, y: x <= y, +} + + +class ComparisonExpression(object): + """ + ComparisonExpression refers to an expression of the form (lhs operator rhs), where lhs and rhs are operands + and operator can be any comparison expression like <, >, <=, >=, ==, != + """ + + def __init__(self, lhs: Union["Promise", Any], op: ComparisonOps, rhs: Union["Promise", Any]): + self._op = op + self._lhs = None + self._rhs = None + if isinstance(lhs, Promise): + self._lhs = lhs + if lhs.is_ready: + if lhs.val.scalar is None or lhs.val.scalar.primitive is None: + union = lhs.val.scalar.union + if union and union.value.scalar: + if union.value.scalar.primitive or union.value.scalar.none_type: + self._lhs = union.value + else: + raise ValueError("Only primitive values can be used in comparison") + else: + raise ValueError("Only primitive values can be used in comparison") + if isinstance(rhs, Promise): + self._rhs = rhs + if rhs.is_ready: + if rhs.val.scalar is None or rhs.val.scalar.primitive is None: + union = rhs.val.scalar.union + if union and union.value.scalar: + if union.value.scalar.primitive or union.value.scalar.none_type: + self._rhs = union.value + else: + raise ValueError("Only primitive values can be used in comparison") + else: + raise ValueError("Only primitive values can be used in comparison") + if self._lhs is None: + self._lhs = type_engine.TypeEngine.to_literal(FlyteContextManager.current_context(), lhs, type(lhs), None) + if self._rhs is None: + self._rhs = type_engine.TypeEngine.to_literal(FlyteContextManager.current_context(), rhs, type(rhs), None) + + @property + def rhs(self) -> Union["Promise", _literals_models.Literal]: + return self._rhs + + @property + def lhs(self) -> Union["Promise", _literals_models.Literal]: + return self._lhs + + @property + def op(self) -> ComparisonOps: + return self._op + + def eval(self) -> bool: + if isinstance(self.lhs, Promise): + lhs = self.lhs.eval() + elif self.lhs.scalar.none_type: + lhs = None + else: + lhs = get_primitive_val(self.lhs.scalar.primitive) + + if isinstance(self.rhs, Promise): + rhs = self.rhs.eval() + elif self.rhs.scalar.none_type: + rhs = None + else: + rhs = get_primitive_val(self.rhs.scalar.primitive) + + return _comparators[self.op](lhs, rhs) + + def __and__(self, other): + return ConjunctionExpression(lhs=self, op=ConjunctionOps.AND, rhs=other) + + def __or__(self, other): + return ConjunctionExpression(lhs=self, op=ConjunctionOps.OR, rhs=other) + + def __bool__(self): + raise ValueError( + "Cannot perform truth value testing," + " This is a limitation in python. For Logical `and\\or` use `&\\|` (bitwise) instead." + f" Expr {self}" + ) + + def __repr__(self): + return f"Comp({self._lhs} {self._op.value} {self._rhs})" + + def __str__(self): + return self.__repr__() + + +class ConjunctionExpression(object): + """ + A Conjunction Expression is an expression of the form either (A and B) or (A or B). + where A, B are two expressions (comparison or conjunctions) and (and, or) are logical truth operators. + + A conjunctionExpression evaluates to True or False depending on the logical operator and the truth values of + each of the expressions A & B + """ + + def __init__( + self, + lhs: Union[ComparisonExpression, "ConjunctionExpression"], + op: ConjunctionOps, + rhs: Union[ComparisonExpression, "ConjunctionExpression"], + ): + self._lhs = lhs + self._rhs = rhs + self._op = op + + @property + def rhs(self) -> Union[ComparisonExpression, "ConjunctionExpression"]: + return self._rhs + + @property + def lhs(self) -> Union[ComparisonExpression, "ConjunctionExpression"]: + return self._lhs + + @property + def op(self) -> ConjunctionOps: + return self._op + + def eval(self) -> bool: + l_eval = self.lhs.eval() + if self.op == ConjunctionOps.AND and l_eval is False: + return False + + if self.op == ConjunctionOps.OR and l_eval is True: + return True + + r_eval = self.rhs.eval() + if self.op == ConjunctionOps.AND: + return l_eval and r_eval + + return l_eval or r_eval + + def __and__(self, other: Union[ComparisonExpression, "ConjunctionExpression"]): + return ConjunctionExpression(lhs=self, op=ConjunctionOps.AND, rhs=other) + + def __or__(self, other: Union[ComparisonExpression, "ConjunctionExpression"]): + return ConjunctionExpression(lhs=self, op=ConjunctionOps.OR, rhs=other) + + def __bool__(self): + raise ValueError( + "Cannot perform truth value testing," + " This is a limitation in python. For Logical `and\\or` use `&\\|` (bitwise) instead. Refer to: PEP-335" + ) + + def __repr__(self): + return f"( {self._lhs} {self._op} {self._rhs} )" + + def __str__(self): + return self.__repr__() + + +# TODO: The NodeOutput object, which this Promise wraps, has an sdk_type. Since we're no longer using sdk types, +# we should consider adding a literal type to this object as well for downstream checking when Bindings are created. +class Promise(object): + """ + This object is a wrapper and exists for three main reasons. Let's assume we're dealing with a task like :: + + @task + def t1() -> (int, str): ... + + #. Handling the duality between compilation and local execution - when the task function is run in a local execution + mode inside a workflow function, a Python integer and string are produced. When the task is being compiled as + part of the workflow, the task call creates a Node instead, and the task returns two Promise objects that + point to that Node. + #. One needs to be able to call :: + + x = t1().with_overrides(...) + + If the task returns an integer or a ``(int, str)`` tuple like ``t1`` above, calling ``with_overrides`` on the + result would throw an error. This Promise object adds that. + #. Assorted handling for conditionals. + """ + + # TODO: Currently, NodeOutput we're creating is the slimmer core package Node class, but since only the + # id is used, it's okay for now. Let's clean all this up though. + def __init__(self, var: str, val: Union[NodeOutput, _literals_models.Literal]): + self._var = var + self._promise_ready = True + self._val = val + self._ref = None + self._attr_path: List[Union[str, int]] = [] + if val and isinstance(val, NodeOutput): + self._ref = val + self._promise_ready = False + self._val = None + + def __hash__(self): + return hash(id(self)) + + def __rshift__(self, other: Union[Promise, VoidPromise]): + if not self.is_ready and other.ref: + self.ref.node.runs_before(other.ref.node) + return other + + def with_var(self, new_var: str) -> Promise: + if self.is_ready: + return Promise(var=new_var, val=self.val) + return Promise(var=new_var, val=self.ref) + + @property + def is_ready(self) -> bool: + """ + Returns if the Promise is READY (is not a reference and the val is actually ready) + Usage: + p = Promise(...) + ... + if p.is_ready(): + print(p.val) + else: + print(p.ref) + """ + return self._promise_ready + + @property + def val(self) -> _literals_models.Literal: + """ + If the promise is ready then this holds the actual evaluate value in Flyte's type system + """ + return self._val + + @property + def ref(self) -> NodeOutput: + """ + If the promise is NOT READY / Incomplete, then it maps to the origin node that owns the promise + """ + return self._ref # type: ignore + + @property + def var(self) -> str: + """ + Name of the variable bound with this promise + """ + return self._var + + @property + def attr_path(self) -> List[Union[str, int]]: + """ + The attribute path the promise will be resolved with. + :rtype: List[Union[str, int]] + """ + return self._attr_path + + def eval(self) -> Any: + if not self._promise_ready or self._val is None: + raise ValueError("Cannot Eval with incomplete promises") + if self.val.scalar is None or self.val.scalar.primitive is None: + raise ValueError("Eval can be invoked for primitive types only") + return get_primitive_val(self.val.scalar.primitive) + + def is_(self, v: bool) -> ComparisonExpression: + return ComparisonExpression(self, ComparisonOps.EQ, v) + + def is_false(self) -> ComparisonExpression: + return self.is_(False) + + def is_true(self) -> ComparisonExpression: + return self.is_(True) + + def is_none(self) -> ComparisonExpression: + return ComparisonExpression(self, ComparisonOps.EQ, None) + + def __eq__(self, other) -> ComparisonExpression: # type: ignore + return ComparisonExpression(self, ComparisonOps.EQ, other) + + def __ne__(self, other) -> ComparisonExpression: # type: ignore + return ComparisonExpression(self, ComparisonOps.NE, other) + + def __gt__(self, other) -> ComparisonExpression: + return ComparisonExpression(self, ComparisonOps.GT, other) + + def __ge__(self, other) -> ComparisonExpression: + return ComparisonExpression(self, ComparisonOps.GE, other) + + def __lt__(self, other) -> ComparisonExpression: + return ComparisonExpression(self, ComparisonOps.LT, other) + + def __le__(self, other) -> ComparisonExpression: + return ComparisonExpression(self, ComparisonOps.LE, other) + + def __bool__(self): + raise ValueError( + "Flytekit does not support Unary expressions or performing truth value testing," + " This is a limitation in python. For Logical `and\\or` use `&\\|` (bitwise) instead" + ) + + def __and__(self, other): + raise ValueError("Cannot perform Logical AND of Promise with other") + + def __or__(self, other): + raise ValueError("Cannot perform Logical OR of Promise with other") + + def with_overrides(self, *args, **kwargs): + if not self.is_ready: + # TODO, this should be forwarded, but right now this results in failure and we want to test this behavior + self.ref.node.with_overrides(*args, **kwargs) + return self + + def __repr__(self): + if self._promise_ready: + return f"Resolved({self._var}={self._val})" + return f"Promise(node:{self.ref.node_id}.{self._var}.{self.attr_path})" + + def __str__(self): + return str(self.__repr__()) + + def deepcopy(self) -> Promise: + new_promise = Promise(var=self.var, val=self.val) + new_promise._promise_ready = self._promise_ready + new_promise._ref = self._ref + new_promise._attr_path = deepcopy(self._attr_path) + return new_promise + + def __getitem__(self, key) -> Promise: + """ + When we use [] to access the attribute on the promise, for example + + ``` + @workflow + def wf(): + o = t1() + t2(x=o["a"][0]) + ``` + + The attribute keys are appended on the promise and a new promise is returned with the updated attribute path. + We don't modify the original promise because it might be used in other places as well. + """ + + return self._append_attr(key) + + def __getattr__(self, key) -> Promise: + """ + When we use . to access the attribute on the promise, for example + + ``` + @workflow + def wf(): + o = t1() + t2(o.a.b) + ``` + + The attribute keys are appended on the promise and a new promise is returned with the updated attribute path. + We don't modify the original promise because it might be used in other places as well. + """ + + return self._append_attr(key) + + def _append_attr(self, key) -> Promise: + new_promise = self.deepcopy() + + # The attr_path on the promise is for local_execute + new_promise._attr_path.append(key) + + if new_promise.ref is not None: + # The attr_path on the ref node is for remote execute + new_promise._ref = new_promise.ref.with_attr(key) + + return new_promise + + +def create_native_named_tuple( + ctx: FlyteContext, + promises: Union[Tuple[Promise], Promise, VoidPromise, None], + entity_interface: Interface, +) -> Optional[Tuple]: + """ + Creates and returns a Named tuple with all variables that match the expected named outputs. this makes + it possible to run things locally and expect a more native behavior, i.e. address elements of a named tuple + by name. + """ + if entity_interface is None: + raise ValueError("Interface of the entity is required to generate named outputs") + + if promises is None: + return None + + 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 TypeError( + f"Failed to convert output in position {key} of value {promises.val}, expected type {v}." + ) from e + + if len(cast(Tuple[Promise], promises)) == 0: + return None + + named_tuple_name = "DefaultNamedTupleOutput" + if entity_interface.output_tuple_name: + named_tuple_name = entity_interface.output_tuple_name + + outputs = {} + 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" + f"type {type(p)}. Workflows cannot return local variables or constants." + ) + t = entity_interface.outputs[p.var] + try: + outputs[p.var] = TypeEngine.to_python_value(ctx, p.val, t) + except Exception as 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? + nt = collections.namedtuple(named_tuple_name, list(outputs.keys())) # type: ignore + return nt(**outputs) + + +# To create a class that is a named tuple, we might have to create namedtuplemeta and manipulate the tuple +def create_task_output( + promises: Optional[Union[List[Promise], Promise]], + entity_interface: Optional[Interface] = None, +) -> Optional[Union[Tuple[Promise], Promise]]: + # TODO: Add VoidPromise here to simplify things at call site. Consider returning for [] below as well instead of + # raising an exception. + if promises is None: + return None + + if isinstance(promises, Promise): + return promises + + if len(promises) == 0: + raise Exception( + "This function should not be called with an empty list. It should have been handled with a" + "VoidPromise at this function's call-site." + ) + + if len(promises) == 1: + if not entity_interface: + return promises[0] + # See transform_function_to_interface for more information, we're using the existence of a name as a proxy + # for the user having specified a one-element typing.NamedTuple, which means we should _not_ extract it. We + # should still return a tuple but it should be one of ours. + if not entity_interface.output_tuple_name: + return promises[0] + + # More than one promise, let us wrap it into a tuple + # Start with just the var names in the promises + variables = [p.var for p in promises] + + # These should be OrderedDicts so it should be safe to iterate over the keys. + if entity_interface: + variables = [k for k in entity_interface.outputs.keys()] + + named_tuple_name = "DefaultNamedTupleOutput" + if entity_interface and entity_interface.output_tuple_name: + named_tuple_name = entity_interface.output_tuple_name + + # Should this class be part of the Interface? + class Output(collections.namedtuple(named_tuple_name, variables)): # type: ignore + def with_overrides(self, *args, **kwargs): + val = self.__getattribute__(self._fields[0]) + val.with_overrides(*args, **kwargs) + return self + + def runs_before(self, other: Any): + """ + This function is just here to allow local workflow execution to run. See the corresponding function in + flytekit.core.node.Node for more information. Local workflow execution in the manual ``create_node`` + paradigm is already determined by the order in which the nodes were created. + """ + # TODO: If possible, add a check and raise an Exception if create_node was not called in the correct order. + return self + + def __rshift__(self, other: Any): + # See comment for runs_before + return other + + return Output(*promises) # type: ignore + + +def binding_data_from_python_std( + ctx: _flyte_context.FlyteContext, + expected_literal_type: _type_models.LiteralType, + t_value: Any, + t_value_type: type, + nodes: List[Node], +) -> _literals_models.BindingData: + # This handles the case where the given value is the output of another task + if isinstance(t_value, Promise): + if not t_value.is_ready: + nodes.append(t_value.ref.node) # keeps track of upstream nodes + return _literals_models.BindingData(promise=t_value.ref) + + elif isinstance(t_value, VoidPromise): + raise AssertionError( + f"Cannot pass output from task {t_value.task_name} that produces no outputs to a downstream task" + ) + + elif t_value is not None and 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, nodes) + 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}." + ) + + elif isinstance(t_value, list): + sub_type: Optional[type] = ListTransformer.get_sub_type_or_none(t_value_type) + collection = _literals_models.BindingDataCollection( + bindings=[ + binding_data_from_python_std(ctx, expected_literal_type.collection_type, t, sub_type or type(t), nodes) + for t in t_value + ] + ) + + return _literals_models.BindingData(collection=collection) + + elif isinstance(t_value, dict): + if ( + expected_literal_type.map_value_type is None + and expected_literal_type.simple != _type_models.SimpleType.STRUCT + ): + raise AssertionError( + f"this should be a Dictionary type and it is not: {type(t_value)} vs {expected_literal_type}" + ) + if expected_literal_type.simple == _type_models.SimpleType.STRUCT: + lit = TypeEngine.to_literal(ctx, t_value, type(t_value), expected_literal_type) + return _literals_models.BindingData(scalar=lit.scalar) + else: + _, v_type = DictTransformer.get_dict_types(t_value_type) + m = _literals_models.BindingDataMap( + bindings={ + k: binding_data_from_python_std( + ctx, expected_literal_type.map_value_type, v, v_type or type(v), nodes + ) + for k, v in t_value.items() + } + ) + return _literals_models.BindingData(map=m) + + elif isinstance(t_value, tuple): + raise AssertionError( + "Tuples are not a supported type for individual values in Flyte - got a tuple -" + f" {t_value}. If using named tuple in an inner task, please, de-reference the" + "actual attribute that you want to use. For example, in NamedTuple('OP', x=int) then" + "return v.x, instead of v, even if this has a single element" + ) + + # This is the scalar case - e.g. my_task(in1=5) + scalar = TypeEngine.to_literal(ctx, t_value, t_value_type or type(t_value), expected_literal_type).scalar + return _literals_models.BindingData(scalar=scalar) + + +def binding_from_python_std( + ctx: _flyte_context.FlyteContext, + var_name: str, + expected_literal_type: _type_models.LiteralType, + t_value: Any, + t_value_type: type, +) -> Tuple[_literals_models.Binding, List[Node]]: + nodes: List[Node] = [] + binding_data = binding_data_from_python_std(ctx, expected_literal_type, t_value, t_value_type, nodes) + return _literals_models.Binding(var=var_name, binding=binding_data), nodes + + +def to_binding(p: Promise) -> _literals_models.Binding: + return _literals_models.Binding(var=p.var, binding=_literals_models.BindingData(promise=p.ref)) + + +class VoidPromise(object): + """ + This object is returned for tasks that do not return any outputs (declared interface is empty) + VoidPromise cannot be interacted with and does not allow comparisons or any operations + """ + + def __init__(self, task_name: str, ref: Optional[NodeOutput] = None): + self._task_name = task_name + self._ref = ref + + def runs_before(self, *args, **kwargs): + """ + This is a placeholder and should do nothing. It is only here to enable local execution of workflows + where a task returns nothing. + """ + + @property + def ref(self) -> Optional[NodeOutput]: + return self._ref + + def __rshift__(self, other: Union[Promise, VoidPromise]): + if self.ref and other.ref: + self.ref.node.runs_before(other.ref.node) + return other + + def with_overrides(self, *args, **kwargs): + if self.ref: + self.ref.node.with_overrides(*args, **kwargs) + return self + + @property + def task_name(self): + return self._task_name + + def __eq__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __and__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __or__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __le__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __ge__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __gt__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __lt__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __add__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __cmp__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __bool__(self): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __mod__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __xor__(self, other): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __str__(self): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + def __repr__(self): + raise AssertionError(f"Task {self._task_name} returns nothing, NoneType return cannot be used") + + +class NodeOutput(type_models.OutputReference): + def __init__(self, node: Node, var: str, attr_path: Optional[List[Union[str, int]]] = None): + """ + :param node: + :param var: The name of the variable this NodeOutput references + """ + if attr_path is None: + attr_path = [] + + self._node = node + super(NodeOutput, self).__init__(self._node.id, var, attr_path) + + @property + def node_id(self): + """ + Override the underlying node_id property to refer to the Node's id. This is to make sure that overriding + node IDs from with_overrides gets serialized correctly. + :rtype: Text + """ + return self.node.id + + @property + def node(self) -> Node: + """Return Node object.""" + return self._node + + def __repr__(self) -> str: + s = f"Node({self.node if self.node.id is not None else None}:{self.var})" + return s + + def deepcopy(self) -> NodeOutput: + return NodeOutput(node=self.node, var=self.var, attr_path=deepcopy(self._attr_path)) + + def with_attr(self, key) -> NodeOutput: + new_node_output = self.deepcopy() + new_node_output._attr_path.append(key) + return new_node_output + + +class SupportsNodeCreation(Protocol): + @property + def name(self) -> str: + ... + + @property + def python_interface(self) -> flyte_interface.Interface: + ... + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + ... + + +class HasFlyteInterface(Protocol): + @property + def name(self) -> str: + ... + + @property + def interface(self) -> _interface_models.TypedInterface: + ... + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + ... + + +def extract_obj_name(name: str) -> str: + """ + Generates a shortened name, without the module information. Useful for node-names etc. Only extracts the final + object information often separated by `.` in the python fully qualified notation + """ + if name is None: + return "" + if "." in name: + return name.split(".")[-1] + return name + + +def create_and_link_node_from_remote( + ctx: FlyteContext, + entity: HasFlyteInterface, + _inputs_not_allowed: Optional[Set[str]] = None, + _ignorable_inputs: Optional[Set[str]] = None, + **kwargs, +) -> Optional[Union[Tuple[Promise], Promise, VoidPromise]]: + """ + This method is used to generate a node with bindings especially when using remote entities, like FlyteWorkflow, + FlyteTask and FlyteLaunchplan. + + This method is kept separate from the similar named method `create_and_link_node` as remote entities have to be + handled differently. The major difference arises from the fact that the remote entities do not have a python + interface, so all comparisons need to happen using the Literals. + + :param ctx: FlyteContext + :param entity: RemoteEntity + :param _inputs_not_allowed: Set of all variable names that should not be provided when using this entity. + Useful for Launchplans with `fixed` inputs + :param _ignorable_inputs: Set of all variable names that are optional, but if provided will be overridden. Useful + for launchplans with `default` inputs + :param kwargs: Dict[str, Any] default inputs passed from the user to this entity. Can be promises. + :return: Optional[Union[Tuple[Promise], Promise, VoidPromise]] + """ + if ctx.compilation_state is None: + raise _user_exceptions.FlyteAssertion("Cannot create node when not compiling...") + + used_inputs = set() + bindings = [] + + typed_interface = entity.interface + + if _inputs_not_allowed: + inputs_not_allowed_specified = _inputs_not_allowed.intersection(kwargs.keys()) + if inputs_not_allowed_specified: + raise _user_exceptions.FlyteAssertion( + f"Fixed inputs cannot be specified. Please remove the following inputs - {inputs_not_allowed_specified}" + ) + nodes = [] + for k in sorted(typed_interface.inputs): + var = typed_interface.inputs[k] + if k not in kwargs: + if _inputs_not_allowed and _ignorable_inputs: + if k in _ignorable_inputs or k in _inputs_not_allowed: + continue + # TODO to improve the error message, should we show python equivalent types for var.type? + raise _user_exceptions.FlyteAssertion("Missing input `{}` type `{}`".format(k, var.type)) + v = kwargs[k] + # This check ensures that tuples are not passed into a function, as tuples are not supported by Flyte + # Usually a Tuple will indicate that multiple outputs from a previous task were accidentally passed + # into the function. + if isinstance(v, tuple): + raise AssertionError( + f"Variable({k}) for function({entity.name}) cannot receive a multi-valued tuple {v}." + f" Check if the predecessor function returning more than one value?" + ) + try: + b, n = binding_from_python_std( + ctx, + var_name=k, + expected_literal_type=var.type, + t_value=v, + t_value_type=type(v), # since we don't have the python type available + ) + bindings.append(b) + nodes.extend(n) + used_inputs.add(k) + except Exception as e: + raise AssertionError(f"Failed to Bind variable {k} for function {entity.name}.") from e + + extra_inputs = used_inputs ^ set(kwargs.keys()) + if len(extra_inputs) > 0: + raise _user_exceptions.FlyteAssertion( + f"Too many inputs for [{entity.name}] Expected inputs: {typed_interface.inputs.keys()} " + f"- extra inputs: {extra_inputs}" + ) + + # Detect upstream nodes + # These will be our core Nodes until we can amend the Promise to use NodeOutputs that reference our Nodes + upstream_nodes = list(set([n for n in nodes if n.id != _common_constants.GLOBAL_INPUT_NODE_ID])) + + flytekit_node = Node( + id=f"{ctx.compilation_state.prefix}n{len(ctx.compilation_state.nodes)}", + metadata=entity.construct_node_metadata(), + bindings=sorted(bindings, key=lambda b: b.var), + upstream_nodes=upstream_nodes, + flyte_entity=entity, + ) + ctx.compilation_state.add_node(flytekit_node) + + if len(typed_interface.outputs) == 0: + return VoidPromise(entity.name, NodeOutput(node=flytekit_node, var="placeholder")) + + # Create a node output object for each output, they should all point to this node of course. + node_outputs = [] + for output_name, output_var_model in typed_interface.outputs.items(): + node_outputs.append(Promise(output_name, NodeOutput(node=flytekit_node, var=output_name))) + + return create_task_output(node_outputs) + + +def create_and_link_node( + ctx: FlyteContext, + entity: SupportsNodeCreation, + **kwargs, +) -> Optional[Union[Tuple[Promise], Promise, VoidPromise]]: + """ + This method is used to generate a node with bindings within a flytekit workflow. this is useful to traverse the + workflow using regular python interpreter and generate nodes and promises whenever an execution is encountered + + :param ctx: FlyteContext + :param entity: RemoteEntity + :param kwargs: Dict[str, Any] default inputs passed from the user to this entity. Can be promises. + :return: Optional[Union[Tuple[Promise], Promise, VoidPromise]] + """ + if ctx.compilation_state is None: + raise _user_exceptions.FlyteAssertion("Cannot create node when not compiling...") + + used_inputs = set() + bindings = [] + nodes = [] + + interface = entity.python_interface + typed_interface = flyte_interface.transform_interface_to_typed_interface(interface) + # Mypy needs some extra help to believe that `typed_interface` will not be `None` + assert typed_interface is not None + + for k in sorted(interface.inputs): + var = typed_interface.inputs[k] + if k not in kwargs: + is_optional = False + if var.type.union_type: + for variant in var.type.union_type.variants: + if variant.simple == SimpleType.NONE: + val, _default = interface.inputs_with_defaults[k] + if _default is not None: + raise ValueError( + f"The default value for the optional type must be None, but got {_default}" + ) + is_optional = True + if not is_optional: + from flytekit.core.base_task import Task + + error_msg = f"Input {k} of type {interface.inputs[k]} was not specified for function {entity.name}" + + _, _default = interface.inputs_with_defaults[k] + if isinstance(entity, Task) and _default is not None: + error_msg += ( + ". Flyte workflow syntax is a domain-specific language (DSL) for building execution graphs which " + "supports a subset of Python’s semantics. When calling tasks, all kwargs have to be provided." + ) + + raise _user_exceptions.FlyteAssertion(error_msg) + else: + continue + v = kwargs[k] + # This check ensures that tuples are not passed into a function, as tuples are not supported by Flyte + # Usually a Tuple will indicate that multiple outputs from a previous task were accidentally passed + # into the function. + if isinstance(v, tuple): + raise AssertionError( + f"Variable({k}) for function({entity.name}) cannot receive a multi-valued tuple {v}." + f" Check if the predecessor function returning more than one value?" + ) + try: + b, n = binding_from_python_std( + ctx, + var_name=k, + expected_literal_type=var.type, + t_value=v, + t_value_type=interface.inputs[k], + ) + bindings.append(b) + nodes.extend(n) + used_inputs.add(k) + except Exception as e: + raise AssertionError(f"Failed to Bind variable {k} for function {entity.name}.") from e + + extra_inputs = used_inputs ^ set(kwargs.keys()) + if len(extra_inputs) > 0: + raise _user_exceptions.FlyteAssertion( + "Too many inputs were specified for the interface. Extra inputs were: {}".format(extra_inputs) + ) + + # Detect upstream nodes + # These will be our core Nodes until we can amend the Promise to use NodeOutputs that reference our Nodes + upstream_nodes = list(set([n for n in nodes if n.id != _common_constants.GLOBAL_INPUT_NODE_ID])) + + flytekit_node = Node( + # TODO: Better naming, probably a derivative of the function name. + id=f"{ctx.compilation_state.prefix}n{len(ctx.compilation_state.nodes)}", + metadata=entity.construct_node_metadata(), + bindings=sorted(bindings, key=lambda b: b.var), + upstream_nodes=upstream_nodes, + flyte_entity=entity, + ) + ctx.compilation_state.add_node(flytekit_node) + + if len(typed_interface.outputs) == 0: + return VoidPromise(entity.name, NodeOutput(node=flytekit_node, var="placeholder")) + + # Create a node output object for each output, they should all point to this node of course. + node_outputs = [] + for output_name, output_var_model in typed_interface.outputs.items(): + node_outputs.append(Promise(output_name, NodeOutput(node=flytekit_node, var=output_name))) + # Don't print this, it'll crash cuz sdk_node._upstream_node_ids might be None, but idl code will break + + return create_task_output(node_outputs, interface) + + +class LocallyExecutable(Protocol): + def local_execute(self, ctx: FlyteContext, **kwargs) -> Union[Tuple[Promise], Promise, VoidPromise, None]: + ... + + def local_execution_mode(self) -> ExecutionState.Mode: + ... + + +def flyte_entity_call_handler( + entity: SupportsNodeCreation, *args, **kwargs +) -> Union[Tuple[Promise], Promise, VoidPromise, Tuple, Coroutine, None]: + """ + This function is the call handler for tasks, workflows, and launch plans (which redirects to the underlying + workflow). The logic is the same for all three, but we did not want to create base class, hence this separate + method. When one of these entities is () aka __called__, there are three things we may do: + #. Compilation Mode - this happens when the function is called as part of a workflow (potentially + dynamic task?). Instead of running the user function, produce promise objects and create a node. + #. Workflow Execution Mode - when a workflow is being run locally. Even though workflows are functions + and everything should be able to be passed through naturally, we'll want to wrap output values of the + function into objects, so that potential .with_cpu or other ancillary functions can be attached to do + nothing. Subsequent tasks will have to know how to unwrap these. If by chance a non-Flyte task uses a + task output as an input, things probably will fail pretty obviously. + #. Start a local execution - This means that we're not already in a local workflow execution, which means that + we should expect inputs to be native Python values and that we should return Python native values. + """ + # Sanity checks + # Only keyword args allowed + if len(args) > 0: + raise _user_exceptions.FlyteAssertion( + f"When calling tasks, only keyword args are supported. " + f"Aborting execution as detected {len(args)} positional args {args}" + ) + # Make sure arguments are part of interface + 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}'" + ) + + ctx = FlyteContextManager.current_context() + if ctx.execution_state and ( + ctx.execution_state.mode == ExecutionState.Mode.TASK_EXECUTION + or ctx.execution_state.mode == ExecutionState.Mode.LOCAL_TASK_EXECUTION + ): + logger.error("You are not supposed to nest @Task/@Workflow inside a @Task!") + if ctx.compilation_state is not None and ctx.compilation_state.mode == 1: + return create_and_link_node(ctx, entity=entity, **kwargs) + if ctx.execution_state and ctx.execution_state.is_local_execution(): + mode = cast(LocallyExecutable, entity).local_execution_mode() + with FlyteContextManager.with_context( + ctx.with_execution_state(ctx.execution_state.with_params(mode=mode)) + ) as child_ctx: + if ( + child_ctx.execution_state + and child_ctx.execution_state.branch_eval_mode == BranchEvalMode.BRANCH_SKIPPED + ): + if ( + len(cast(SupportsNodeCreation, entity).python_interface.inputs) > 0 + or len(cast(SupportsNodeCreation, entity).python_interface.outputs) > 0 + ): + output_names = list(cast(SupportsNodeCreation, entity).python_interface.outputs.keys()) + if len(output_names) == 0: + return VoidPromise(entity.name) + vals = [Promise(var, None) for var in output_names] + return create_task_output(vals, cast(SupportsNodeCreation, entity).python_interface) + else: + return None + return cast(LocallyExecutable, entity).local_execute(ctx, **kwargs) + else: + mode = cast(LocallyExecutable, entity).local_execution_mode() + with FlyteContextManager.with_context( + ctx.with_execution_state(ctx.new_execution_state().with_params(mode=mode)) + ) as child_ctx: + cast(ExecutionParameters, child_ctx.user_space_params)._decks = [] + result = cast(LocallyExecutable, entity).local_execute(child_ctx, **kwargs) + + expected_outputs = len(cast(SupportsNodeCreation, entity).python_interface.outputs) + if expected_outputs == 0: + if result is None or isinstance(result, VoidPromise): + return None + else: + raise Exception(f"Received an output when workflow local execution expected None. Received: {result}") + + if inspect.iscoroutine(result): + return result + + if (1 < expected_outputs == len(cast(Tuple[Promise], result))) or ( + result is not None and expected_outputs == 1 + ): + return create_native_named_tuple(ctx, result, cast(SupportsNodeCreation, entity).python_interface) + + raise ValueError( + f"Expected outputs and actual outputs do not match." + f"Result {result}. " + f"Python interface: {cast(SupportsNodeCreation, entity).python_interface}" + ) diff --git a/flytekit/flytekit/core/python_auto_container.py b/flytekit/flytekit/core/python_auto_container.py new file mode 100644 index 0000000000..c43e3d4d14 --- /dev/null +++ b/flytekit/flytekit/core/python_auto_container.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import importlib +import re +from abc import ABC +from typing import Callable, Dict, List, Optional, TypeVar, Union + +from flyteidl.core import tasks_pb2 + +from flytekit.configuration import ImageConfig, SerializationSettings +from flytekit.core.base_task import PythonTask, TaskMetadata, TaskResolverMixin +from flytekit.core.context_manager import FlyteContextManager +from flytekit.core.pod_template import PodTemplate +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, _serialize_pod_spec, timeit +from flytekit.extras.accelerators import BaseAccelerator +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 + +T = TypeVar("T") +_PRIMARY_CONTAINER_NAME_FIELD = "primary_container_name" + + +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 + container and the container information to be automatically captured. + This base will auto configure the image and image version to be used for all its derivatives. + + If you are looking to extend, you might prefer to use ``PythonFunctionTask`` or ``PythonInstanceTask`` + """ + + def __init__( + self, + name: str, + task_config: T, + task_type="python-task", + container_image: Optional[Union[str, ImageSpec]] = None, + requests: Optional[Resources] = None, + limits: Optional[Resources] = None, + environment: Optional[Dict[str, str]] = None, + task_resolver: Optional[TaskResolverMixin] = None, + secret_requests: Optional[List[Secret]] = None, + pod_template: Optional[PodTemplate] = None, + pod_template_name: Optional[str] = None, + accelerator: Optional[BaseAccelerator] = None, + **kwargs, + ): + """ + :param name: unique name for the task, usually the function's module and name. + :param task_config: Configuration object for Task. Should be a unique type for that specific Task. + :param task_type: String task type to be associated with this Task + :param container_image: String FQN for the image. + :param requests: custom resource request settings. + :param limits: custom resource limit settings. + :param environment: Environment variables you want the task to have when run. + :param task_resolver: Custom resolver - will pick up the default resolver if empty, or the resolver set + in the compilation context if one is set. + :param List[Secret] secret_requests: Secrets that are requested by this container execution. These secrets will + be mounted based on the configuration in the Secret and available through + the SecretManager using the name of the secret as the group + Ideally the secret keys should also be semi-descriptive. + The key values will be available from runtime, if the backend is configured + to provide secrets and if secrets are available in the configured secrets store. + Possible options for secret stores are + + - `Vault `__ + - `Confidant `__ + - `Kube secrets `__ + - `AWS Parameter store `__ + :param pod_template: Custom PodTemplate for this task. + :param pod_template_name: The name of the existing PodTemplate resource which will be used in this task. + :param accelerator: The accelerator to use for this task. + """ + sec_ctx = None + if secret_requests: + for s in secret_requests: + 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 + kwargs["metadata"] = kwargs["metadata"] if "metadata" in kwargs else TaskMetadata() + kwargs["metadata"].pod_template_name = pod_template_name + + super().__init__( + task_type=task_type, + name=name, + task_config=task_config, + security_ctx=sec_ctx, + **kwargs, + ) + self._container_image = container_image + # TODO(katrogan): Implement resource overrides + self._resources = ResourceSpec( + requests=requests if requests else Resources(), limits=limits if limits else Resources() + ) + self._environment = environment or {} + + compilation_state = FlyteContextManager.current_context().compilation_state + if compilation_state and compilation_state.task_resolver: + if task_resolver: + logger.info( + f"Not using the passed in task resolver {task_resolver} because one found in compilation context" + ) + self._task_resolver = compilation_state.task_resolver + if self._task_resolver.task_name(self) is not None: + self._name = self._task_resolver.task_name(self) or "" + else: + self._task_resolver = task_resolver or default_task_resolver + self._get_command_fn = self.get_default_command + + self.pod_template = pod_template + self.accelerator = accelerator + + @property + def task_resolver(self) -> TaskResolverMixin: + return self._task_resolver + + @property + def container_image(self) -> Optional[Union[str, ImageSpec]]: + return self._container_image + + @property + def resources(self) -> ResourceSpec: + return self._resources + + def get_default_command(self, settings: SerializationSettings) -> List[str]: + """ + Returns the default pyflyte-execute command used to run this on hosted Flyte platforms. + """ + container_args = [ + "pyflyte-execute", + "--inputs", + "{{.input}}", + "--output-prefix", + "{{.outputPrefix}}", + "--raw-output-data-prefix", + "{{.rawOutputDataPrefix}}", + "--checkpoint-path", + "{{.checkpointOutputPrefix}}", + "--prev-checkpoint", + "{{.prevCheckpointPrefix}}", + "--resolver", + self.task_resolver.location, + "--", + *self.task_resolver.loader_args(settings, self), + ] + + return container_args + + def set_command_fn(self, get_command_fn: Optional[Callable[[SerializationSettings], List[str]]] = None): + """ + By default, the task will run on the Flyte platform using the pyflyte-execute command. + However, it can be useful to update the command with which the task is serialized for specific cases like + running map tasks ("pyflyte-map-execute") or for fast-executed tasks. + """ + self._get_command_fn = get_command_fn # type: ignore + + def reset_command_fn(self): + """ + Resets the command which should be used in the container definition of this task to the default arguments. + This is useful when the command line is overridden at serialization time. + """ + self._get_command_fn = self.get_default_command + + def get_command(self, settings: SerializationSettings) -> List[str]: + """ + Returns the command which should be used in the container definition for the serialized version of this task + registered on a hosted Flyte platform. + """ + return self._get_command_fn(settings) + + def get_container(self, settings: SerializationSettings) -> _task_model.Container: + # if pod_template is not None, return None here but in get_k8s_pod, return pod_template merged with container + if self.pod_template is not None: + return None + else: + return self._get_container(settings) + + def _get_container(self, settings: SerializationSettings) -> _task_model.Container: + env = {} + 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=[], + args=self.get_command(settings=settings), + data_loading_config=None, + environment=env, + ephemeral_storage_request=self.resources.requests.ephemeral_storage, + cpu_request=self.resources.requests.cpu, + gpu_request=self.resources.requests.gpu, + memory_request=self.resources.requests.mem, + ephemeral_storage_limit=self.resources.limits.ephemeral_storage, + cpu_limit=self.resources.limits.cpu, + 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), settings), + metadata=_task_model.K8sObjectMetadata( + labels=self.pod_template.labels, + annotations=self.pod_template.annotations, + ), + ) + + # need to call super in all its children tasks + 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} + + def get_extended_resources(self, settings: SerializationSettings) -> Optional[tasks_pb2.ExtendedResources]: + """ + Returns the extended resources to allocate to the task on hosted Flyte. + """ + if self.accelerator is None: + return None + + return tasks_pb2.ExtendedResources(gpu_accelerator=self.accelerator.to_flyte_idl()) + + +class DefaultTaskResolver(TrackedInstance, TaskResolverMixin): + """ + Please see the notes in the TaskResolverMixin as it describes this default behavior. + """ + + def name(self) -> str: + return "DefaultTaskResolver" + + @timeit("Load task") + def load_task(self, loader_args: List[str]) -> PythonAutoContainerTask: + _, task_module, _, task_name, *_ = loader_args + + task_module = importlib.import_module(name=task_module) # type: ignore + task_def = getattr(task_module, task_name) + return task_def + + def loader_args(self, settings: SerializationSettings, task: PythonAutoContainerTask) -> List[str]: # type:ignore + _, m, t, _ = extract_task_module(task) + return ["task-module", m, "task-name", t] + + def get_all_tasks(self) -> List[PythonAutoContainerTask]: # type: ignore + raise Exception("should not be needed") + + +default_task_resolver = DefaultTaskResolver() + + +def get_registerable_container_image(img: Optional[Union[str, ImageSpec]], cfg: ImageConfig) -> str: + """ + Resolve the image to the real image name that should be used for registration. + 1. If img is a ImageSpec, it will be built and the image name will be returned + 2. If img is a placeholder string (e.g. {{.image.default.fqn}}:{{.image.default.version}}), + it will be resolved using the cfg and the image name will be returned + + :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: + return img + for m in matches: + if len(m) < 3: + raise AssertionError( + "Image specification should be of the form : OR :{{.image.default.version}} OR " + f"{{.image.xyz.fqn}}:{{.image.xyz.version}} OR {{.image.xyz}} - Received {m}" + ) + replace_group, name, attr = m + if name is None or name == "": + raise AssertionError(f"Image format is incorrect {m}") + img_cfg = cfg.find_image(name) + if img_cfg is None: + raise AssertionError(f"Image Config with name {name} not found in the configuration") + if attr == "version": + if img_cfg.tag is not None: + img = img.replace(replace_group, img_cfg.tag) + else: + img = img.replace(replace_group, cfg.default_image.tag) + elif attr == "fqn": + img = img.replace(replace_group, img_cfg.fqn) + elif attr == "": + img = img.replace(replace_group, img_cfg.full) + else: + raise AssertionError(f"Only fqn and version are supported replacements, {attr} is not supported") + return img + if cfg.default_image is None: + raise ValueError("An image is required for PythonAutoContainer tasks") + return f"{cfg.default_image.fqn}:{cfg.default_image.tag}" + + +# Matches {{.image..}}. A name can be either 'default' indicating the default image passed during +# serialization or it can be a custom name for an image that must be defined in the config section Images. An attribute +# can be either 'fqn', 'version' or non-existent. +# fqn will access the fully qualified name of the image (e.g. registry/imagename:version -> registry/imagename) +# version will access the version part of the image (e.g. registry/imagename:version -> version) +# With empty attribute, it'll access the full image path (e.g. registry/imagename:version -> registry/imagename:version) +_IMAGE_REPLACE_REGEX = re.compile(r"({{\s*\.image[s]?(?:\.([a-zA-Z0-9_]+))(?:\.([a-zA-Z0-9_]+))?\s*}})", re.IGNORECASE) diff --git a/flytekit/flytekit/core/python_customized_container_task.py b/flytekit/flytekit/core/python_customized_container_task.py new file mode 100644 index 0000000000..a3d89b0979 --- /dev/null +++ b/flytekit/flytekit/core/python_customized_container_task.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional, Type, TypeVar + +from flyteidl.core import tasks_pb2 as _tasks_pb2 + +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.core.base_task import PythonTask, Task, TaskResolverMixin +from flytekit.core.context_manager import FlyteContext +from flytekit.core.resources import Resources, ResourceSpec +from flytekit.core.shim_task import ExecutableTemplateShimTask, ShimTaskExecutor +from flytekit.core.tracker import TrackedInstance +from flytekit.core.utils import _get_container_definition, load_proto_from_file +from flytekit.loggers import logger +from flytekit.models import task as _task_model +from flytekit.models.core import identifier as identifier_models +from flytekit.models.security import Secret, SecurityContext +from flytekit.tools.module_loader import load_object_from_module + +TC = TypeVar("TC") + + +class PythonCustomizedContainerTask(ExecutableTemplateShimTask, PythonTask[TC]): # type: ignore + """ + Please take a look at the comments for :py:class`flytekit.extend.ExecutableTemplateShimTask` as well. This class + should be subclassed and a custom Executor provided as a default to this parent class constructor + when building a new external-container flytekit-only plugin. + + This class provides authors of new task types the basic scaffolding to create task-template based tasks. In order + to write such a task, authors need to + + * subclass the ``ShimTaskExecutor`` class and override the ``execute_from_model`` function. This function is + where all the business logic should go. Keep in mind though that you, the plugin author, will not have access + to anything that's not serialized within the ``TaskTemplate`` which is why you'll also need to + * subclass this class, and override the ``get_custom`` function to include all the information the executor + will need to run. + * Also pass the executor you created as the ``executor_type`` argument of this class's constructor. + + Keep in mind that the total size of the ``TaskTemplate`` still needs to be small, since these will be accessed + frequently by the Flyte engine. + """ + + SERIALIZE_SETTINGS = SerializationSettings( + project="PLACEHOLDER_PROJECT", + domain="LOCAL", + version="PLACEHOLDER_VERSION", + env=None, + image_config=ImageConfig( + default_image=Image(name="custom_container_task", fqn="flyteorg.io/placeholder", tag="image") + ), + ) + + def __init__( + self, + name: str, + task_config: TC, + container_image: str, + executor_type: Type[ShimTaskExecutor], + task_resolver: Optional[TaskTemplateResolver] = None, + task_type="python-task", + requests: Optional[Resources] = None, + limits: Optional[Resources] = None, + environment: Optional[Dict[str, str]] = None, + secret_requests: Optional[List[Secret]] = None, + **kwargs, + ): + """ + :param name: unique name for the task, usually the function's module and name. + :param task_config: Configuration object for Task. Should be a unique type for that specific Task + :param container_image: This is the external container image the task should run at platform-run-time. + :param executor: This is an executor which will actually provide the business logic. + :param task_resolver: Custom resolver - if you don't make one, use the default task template resolver. + :param task_type: String task type to be associated with this Task. + :param requests: custom resource request settings. + :param limits: custom resource limit settings. + :param environment: Environment variables you want the task to have when run. + :param List[Secret] secret_requests: Secrets that are requested by this container execution. These secrets will + be mounted based on the configuration in the Secret and available through + the SecretManager using the name of the secret as the group + Ideally the secret keys should also be semi-descriptive. + The key values will be available from runtime, if the backend is configured to provide secrets and + if secrets are available in the configured secrets store. Possible options for secret stores are + + - `Vault `__ + - `Confidant `__ + - `Kube secrets `__ + - `AWS Parameter store `__ + """ + sec_ctx = None + if secret_requests: + for s in secret_requests: + 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) + super().__init__( + tt=None, + executor_type=executor_type, + task_type=task_type, + name=name, + task_config=task_config, + security_ctx=sec_ctx, + **kwargs, + ) + self._resources = ResourceSpec( + requests=requests if requests else Resources(), limits=limits if limits else Resources() + ) + self._environment = environment or {} + self._container_image = container_image + self._task_resolver = task_resolver or default_task_template_resolver + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + # Overriding base implementation to raise an error, force task author to implement + raise NotImplementedError + + def get_config(self, settings: SerializationSettings) -> Dict[str, str]: + # Overriding base implementation but not doing anything. Technically this should be the task config, + # but the IDL limitation that the value also has to be a string is very limiting. + # Recommend putting information you need in the config into custom instead, because when serializing + # the custom field, we jsonify custom and the place it into a protobuf struct. This config field + # just gets put into a Dict[str, str] + return {} + + @property + def resources(self) -> ResourceSpec: + return self._resources + + @property + def task_resolver(self) -> TaskTemplateResolver: + return self._task_resolver + + @property + def task_template(self) -> Optional[_task_model.TaskTemplate]: + """ + Override the base class implementation to serialize on first call. + """ + return self._task_template or self.serialize_to_model(settings=PythonCustomizedContainerTask.SERIALIZE_SETTINGS) + + @property + def container_image(self) -> str: + return self._container_image + + def get_command(self, settings: SerializationSettings) -> List[str]: + container_args = [ + "pyflyte-execute", + "--inputs", + "{{.input}}", + "--output-prefix", + "{{.outputPrefix}}", + "--raw-output-data-prefix", + "{{.rawOutputDataPrefix}}", + "--resolver", + self.task_resolver.location, + "--", + *self.task_resolver.loader_args(settings, self), + ] + + return container_args + + def get_container(self, settings: SerializationSettings) -> _task_model.Container: + env = {**settings.env, **self.environment} if self.environment else settings.env + return _get_container_definition( + image=self.container_image, + command=[], + args=self.get_command(settings=settings), + data_loading_config=None, + environment=env, + ephemeral_storage_request=self.resources.requests.ephemeral_storage, + cpu_request=self.resources.requests.cpu, + gpu_request=self.resources.requests.gpu, + memory_request=self.resources.requests.mem, + ephemeral_storage_limit=self.resources.limits.ephemeral_storage, + cpu_limit=self.resources.limits.cpu, + gpu_limit=self.resources.limits.gpu, + memory_limit=self.resources.limits.mem, + ) + + def serialize_to_model(self, settings: SerializationSettings) -> _task_model.TaskTemplate: + # This doesn't get called from translator unfortunately. Will need to move the translator to use the model + # objects directly first. + # Note: This doesn't settle the issue of duplicate registrations. We'll need to figure that out somehow. + # TODO: After new control plane classes are in, promote the template to a FlyteTask, so that authors of + # customized-container tasks have a familiar thing to work with. + obj = _task_model.TaskTemplate( + identifier_models.Identifier( + identifier_models.ResourceType.TASK, settings.project, settings.domain, self.name, settings.version + ), + self.task_type, + self.metadata.to_taskmetadata_model(), + self.interface, + self.get_custom(settings), + container=self.get_container(settings), + config=self.get_config(settings), + ) + self._task_template = obj + return obj + + +class TaskTemplateResolver(TrackedInstance, TaskResolverMixin): + """ + This is a special resolver that resolves the task above at execution time, using only the ``TaskTemplate``, + meaning it should only be used for tasks that contain all pertinent information within the template itself. + + This class differs from some TaskResolverMixin pattern a bit. Most of the other resolvers you'll find, + + * restores the same task when ``load_task`` is called as the object that ``loader_args`` was called on. + That is, even though at run time it's in a container on a cluster and is obviously a different Python process, + the Python object in memory should look the same. + * offers a one-to-one mapping between the list of strings returned by the ``loader_args`` function, an the task, + at least within the container. + + This resolver differs in that, + * when loading a task, the task that is a loaded is always an ``ExecutableTemplateShimTask``, regardless of what + kind of task it was originally. It will only ever have what's available to it from the ``TaskTemplate``. No + information that wasn't serialized into the template will be available. + * all tasks will result in the same list of strings for a given subclass of the ``ShimTaskExecutor`` + executor. The strings will be ``["{{.taskTemplatePath}}", "path.to.your.executor"]`` + + Also, ``get_all_tasks`` will always return an empty list, at least for now. + """ + + def __init__(self): + super(TaskTemplateResolver, self).__init__() + + def name(self) -> str: + return "task template resolver" + + # The return type of this function is different, it should be a Task, but it's not because it doesn't make + # sense for ExecutableTemplateShimTask to inherit from Task. + def load_task(self, loader_args: List[str]) -> ExecutableTemplateShimTask: # type: ignore + logger.info(f"Task template loader args: {loader_args}") + ctx = FlyteContext.current_context() + task_template_local_path = os.path.join(ctx.execution_state.working_dir, "task_template.pb") # type: ignore + ctx.file_access.get_data(loader_args[0], task_template_local_path) + task_template_proto = load_proto_from_file(_tasks_pb2.TaskTemplate, task_template_local_path) + task_template_model = _task_model.TaskTemplate.from_flyte_idl(task_template_proto) + + executor_class = load_object_from_module(loader_args[1]) + return ExecutableTemplateShimTask(task_template_model, executor_class) + + def loader_args(self, settings: SerializationSettings, t: PythonCustomizedContainerTask) -> List[str]: # type: ignore + return ["{{.taskTemplatePath}}", f"{t.executor_type.__module__}.{t.executor_type.__name__}"] + + def get_all_tasks(self) -> List[Task]: + return [] + + +default_task_template_resolver = TaskTemplateResolver() diff --git a/flytekit/flytekit/core/python_function_task.py b/flytekit/flytekit/core/python_function_task.py new file mode 100644 index 0000000000..c26bdd6c6e --- /dev/null +++ b/flytekit/flytekit/core/python_function_task.py @@ -0,0 +1,347 @@ +""" +========================================= +:mod:`flytekit.core.python_function_task` +========================================= + +.. currentmodule:: flytekit.core.python_function_task + +.. autosummary:: + :toctree: generated/ + + PythonFunctionTask + PythonInstanceTask + +""" + +from __future__ import annotations + +from abc import ABC +from collections import OrderedDict +from enum import Enum +from typing import Any, Callable, Iterable, List, Optional, TypeVar, Union, cast + +from flytekit.core import launch_plan as _annotated_launch_plan +from flytekit.core.base_task import Task, TaskResolverMixin +from flytekit.core.context_manager import ExecutionState, FlyteContext, FlyteContextManager +from flytekit.core.docstring import Docstring +from flytekit.core.interface import transform_function_to_interface +from flytekit.core.promise import VoidPromise, translate_inputs_to_literals +from flytekit.core.python_auto_container import PythonAutoContainerTask, default_task_resolver +from flytekit.core.tracker import extract_task_module, is_functools_wrapped_module_level, isnested, istestfunction +from flytekit.core.workflow import ( + PythonFunctionWorkflow, + WorkflowBase, + WorkflowFailurePolicy, + WorkflowMetadata, + WorkflowMetadataDefaults, +) +from flytekit.exceptions import scopes as exception_scopes +from flytekit.exceptions.user import FlyteValueException +from flytekit.loggers import logger +from flytekit.models import dynamic_job as _dynamic_job +from flytekit.models import literals as _literal_models +from flytekit.models import task as task_models +from flytekit.models.admin import workflow as admin_workflow_models + +T = TypeVar("T") + + +class PythonInstanceTask(PythonAutoContainerTask[T], ABC): # type: ignore + """ + This class should be used as the base class for all Tasks that do not have a user defined function body, but have + a platform defined execute method. (Execute needs to be overridden). This base class ensures that the module loader + will invoke the right class automatically, by capturing the module name and variable in the module name. + + .. code-block: python + + x = MyInstanceTask(name="x", .....) + + # this can be invoked as + x(a=5) # depending on the interface of the defined task + + """ + + def __init__( + self, + name: str, + task_config: T, + task_type: str = "python-task", + task_resolver: Optional[TaskResolverMixin] = None, + **kwargs, + ): + """ + Please see class level documentation. + """ + super().__init__(name=name, task_config=task_config, task_type=task_type, task_resolver=task_resolver, **kwargs) + + +class PythonFunctionTask(PythonAutoContainerTask[T]): # type: ignore + """ + A Python Function task should be used as the base for all extensions that have a python function. It will + automatically detect interface of the python function and when serialized on the hosted Flyte platform handles the + writing execution command to execute the function + + It is advised this task is used using the @task decorator as follows + + .. code-block: python + + @task + def my_func(a: int) -> str: + ... + + In the above code, the name of the function, the module, and the interface (inputs = int and outputs = str) will be + auto detected. + """ + + class ExecutionBehavior(Enum): + DEFAULT = 1 + DYNAMIC = 2 + EAGER = 3 + + def __init__( + self, + task_config: T, + task_function: Callable, + task_type="python-task", + ignore_input_vars: Optional[List[str]] = None, + execution_mode: ExecutionBehavior = ExecutionBehavior.DEFAULT, + task_resolver: Optional[TaskResolverMixin] = None, + node_dependency_hints: Optional[ + Iterable[Union["PythonFunctionTask", "_annotated_launch_plan.LaunchPlan", WorkflowBase]] + ] = None, + **kwargs, + ): + """ + :param T task_config: Configuration object for Task. Should be a unique type for that specific Task + :param Callable task_function: Python function that has type annotations and works for the task + :param Optional[List[str]] ignore_input_vars: When supplied, these input variables will be removed from the interface. This + can be used to inject some client side variables only. Prefer using ExecutionParams + :param Optional[ExecutionBehavior] execution_mode: Defines how the execution should behave, for example + executing normally or specially handling a dynamic case. + :param str task_type: String task type to be associated with this Task + :param Optional[Iterable[Union["PythonFunctionTask", "_annotated_launch_plan.LaunchPlan", WorkflowBase]]] node_dependency_hints: + A list of tasks, launchplans, or workflows that this task depends on. This is only + for dynamic tasks/workflows, where flyte cannot automatically determine the dependencies prior to runtime. + """ + if task_function is None: + raise ValueError("TaskFunction is a required parameter for PythonFunctionTask") + self._native_interface = transform_function_to_interface(task_function, Docstring(callable_=task_function)) + mutated_interface = self._native_interface.remove_inputs(ignore_input_vars) + name, _, _, _ = extract_task_module(task_function) + super().__init__( + task_type=task_type, + name=name, + interface=mutated_interface, + task_config=task_config, + task_resolver=task_resolver, + **kwargs, + ) + + if self._task_resolver is default_task_resolver: + # The default task resolver can't handle nested functions + # TODO: Consider moving this to a can_handle function or something inside the resolver itself. + if ( + not istestfunction(func=task_function) + and isnested(func=task_function) + and not is_functools_wrapped_module_level(task_function) + ): + raise ValueError( + "TaskFunction cannot be a nested/inner or local function. " + "It should be accessible at a module level for Flyte to execute it. Test modules with " + "names beginning with `test_` are allowed to have nested tasks. " + "If you're decorating your task function with custom decorators, use functools.wraps " + "or functools.update_wrapper on the function wrapper. " + "Alternatively if you want to create your own tasks with custom behavior use the TaskResolverMixin" + ) + self._task_function = task_function + self._execution_mode = execution_mode + self._node_dependency_hints = node_dependency_hints + if self._node_dependency_hints is not None and self._execution_mode != self.ExecutionBehavior.DYNAMIC: + raise ValueError( + "node_dependency_hints should only be used on dynamic tasks. On static tasks and " + "workflows its redundant because flyte can find the node dependencies automatically" + ) + self._wf = None # For dynamic tasks + + @property + def execution_mode(self) -> ExecutionBehavior: + return self._execution_mode + + @property + def node_dependency_hints( + self, + ) -> Optional[Iterable[Union["PythonFunctionTask", "_annotated_launch_plan.LaunchPlan", WorkflowBase]]]: + return self._node_dependency_hints + + @property + def task_function(self): + return self._task_function + + @property + def name(self) -> str: + """ + Returns the name of the task. + """ + if self.instantiated_in and self.instantiated_in not in self._name: + return f"{self.instantiated_in}.{self._name}" + return self._name + + def execute(self, **kwargs) -> Any: + """ + This method will be invoked to execute the task. If you do decide to override this method you must also + handle dynamic tasks or you will no longer be able to use the task as a dynamic task generator. + """ + if self.execution_mode == self.ExecutionBehavior.DEFAULT: + return exception_scopes.user_entry_point(self._task_function)(**kwargs) + elif self.execution_mode == self.ExecutionBehavior.EAGER: + # if the task is a coroutine function, inject the context object so that the async_entity + # has access to the FlyteContext. + kwargs["async_ctx"] = FlyteContextManager.current_context() + return exception_scopes.user_entry_point(self._task_function)(**kwargs) + elif self.execution_mode == self.ExecutionBehavior.DYNAMIC: + return self.dynamic_execute(self._task_function, **kwargs) + + def _create_and_cache_dynamic_workflow(self): + if self._wf is None: + workflow_meta = WorkflowMetadata(on_failure=WorkflowFailurePolicy.FAIL_IMMEDIATELY) + defaults = WorkflowMetadataDefaults( + interruptible=self.metadata.interruptible if self.metadata.interruptible is not None else False + ) + self._wf = PythonFunctionWorkflow(self._task_function, metadata=workflow_meta, default_metadata=defaults) + + def compile_into_workflow( + self, ctx: FlyteContext, task_function: Callable, **kwargs + ) -> Union[_dynamic_job.DynamicJobSpec, _literal_models.LiteralMap]: + """ + In the case of dynamic workflows, this function will produce a workflow definition at execution time which will + then proceed to be executed. + """ + # TODO: circular import + from flytekit.core.task import ReferenceTask + + if not ctx.compilation_state: + cs = ctx.new_compilation_state(prefix="d") + else: + cs = ctx.compilation_state.with_params(prefix="d") + + updated_ctx = ctx.with_compilation_state(cs) + if self.execution_mode == self.ExecutionBehavior.DYNAMIC: + es = ctx.new_execution_state().with_params(mode=ExecutionState.Mode.DYNAMIC_TASK_EXECUTION) + updated_ctx = updated_ctx.with_execution_state(es) + + with FlyteContextManager.with_context(updated_ctx): + # TODO: Resolve circular import + from flytekit.tools.translator import get_serializable + + self._create_and_cache_dynamic_workflow() + cast(PythonFunctionWorkflow, self._wf).compile(**kwargs) + + wf = self._wf + model_entities: OrderedDict = OrderedDict() + # See comment on reference entity checking a bit down below in this function. + # This is the only circular dependency between the translator.py module and the rest of the flytekit + # authoring experience. + workflow_spec: admin_workflow_models.WorkflowSpec = get_serializable( + model_entities, ctx.serialization_settings, wf + ) + + # If no nodes were produced, let's just return the strict outputs + if len(workflow_spec.template.nodes) == 0: + return _literal_models.LiteralMap( + literals={ + binding.var: binding.binding.to_literal_model() for binding in workflow_spec.template.outputs + } + ) + + # Gather underlying TaskTemplates that get referenced. + tts = [] + for entity, model in model_entities.items(): + # We only care about gathering tasks here. Launch plans are handled by + # propeller. Subworkflows should already be in the workflow spec. + if not isinstance(entity, Task) and not isinstance(entity, task_models.TaskSpec): + continue + + # We are currently not supporting reference tasks since these will + # require a network call to flyteadmin to populate the TaskTemplate + # model + if isinstance(entity, ReferenceTask): + raise Exception("Reference tasks are currently unsupported within dynamic tasks") + + if not isinstance(model, task_models.TaskSpec): + raise TypeError( + f"Unexpected type for serialized form of task. Expected {task_models.TaskSpec}, but got {type(model)}" + ) + + # Store the valid task template so that we can pass it to the + # DynamicJobSpec later + tts.append(model.template) + + dj_spec = _dynamic_job.DynamicJobSpec( + min_successes=len(workflow_spec.template.nodes), + tasks=tts, + nodes=workflow_spec.template.nodes, + outputs=workflow_spec.template.outputs, + subworkflows=workflow_spec.sub_workflows, + ) + + return dj_spec + + def dynamic_execute(self, task_function: Callable, **kwargs) -> Any: + """ + By the time this function is invoked, the local_execute function should have unwrapped the Promises and Flyte + literal wrappers so that the kwargs we are working with here are now Python native literal values. This + function is also expected to return Python native literal values. + + Since the user code within a dynamic task constitute a workflow, we have to first compile the workflow, and + then execute that workflow. + + When running for real in production, the task would stop after the compilation step, and then create a file + representing that newly generated workflow, instead of executing it. + """ + ctx = FlyteContextManager.current_context() + if ctx.execution_state and ctx.execution_state.is_local_execution(): + # The rest of this function mimics the local_execute of the workflow. We can't use the workflow + # local_execute directly though since that converts inputs into Promises. + logger.debug(f"Executing Dynamic workflow, using raw inputs {kwargs}") + self._create_and_cache_dynamic_workflow() + function_outputs = cast(PythonFunctionWorkflow, self._wf).execute(**kwargs) + + if isinstance(function_outputs, VoidPromise) or function_outputs is None: + return VoidPromise(self.name) + + if len(cast(PythonFunctionWorkflow, self._wf).python_interface.outputs) == 0: + raise FlyteValueException(function_outputs, "Interface output should've been VoidPromise or None.") + + # TODO: This will need to be cleaned up when we revisit top-level tuple support. + expected_output_names = list(self.python_interface.outputs.keys()) + if len(expected_output_names) == 1: + # Here we have to handle the fact that the wf could've been declared with a typing.NamedTuple of + # length one. That convention is used for naming outputs - and single-length-NamedTuples are + # particularly troublesome but elegant handling of them is not a high priority + # Again, we're using the output_tuple_name as a proxy. + if self.python_interface.output_tuple_name and isinstance(function_outputs, tuple): + wf_outputs_as_map = {expected_output_names[0]: function_outputs[0]} + else: + wf_outputs_as_map = {expected_output_names[0]: function_outputs} + else: + wf_outputs_as_map = { + expected_output_names[i]: function_outputs[i] for i, _ in enumerate(function_outputs) + } + + # In a normal workflow, we'd repackage the promises coming from tasks into new Promises matching the + # workflow's interface. For a dynamic workflow, just return the literal map. + wf_outputs_as_literal_dict = translate_inputs_to_literals( + ctx, + wf_outputs_as_map, + flyte_interface_types=self.interface.outputs, + native_types=self.python_interface.outputs, + ) + return _literal_models.LiteralMap(literals=wf_outputs_as_literal_dict) + + if ctx.execution_state and ctx.execution_state.mode == ExecutionState.Mode.TASK_EXECUTION: + return self.compile_into_workflow(ctx, task_function, **kwargs) + + if ctx.execution_state and ctx.execution_state.mode == ExecutionState.Mode.LOCAL_TASK_EXECUTION: + return exception_scopes.user_entry_point(task_function)(**kwargs) + + raise ValueError(f"Invalid execution provided, execution state: {ctx.execution_state}") diff --git a/flytekit/flytekit/core/reference.py b/flytekit/flytekit/core/reference.py new file mode 100644 index 0000000000..6a88549c43 --- /dev/null +++ b/flytekit/flytekit/core/reference.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Dict, Type + +from flytekit.core.launch_plan import ReferenceLaunchPlan +from flytekit.core.task import ReferenceTask +from flytekit.core.workflow import ReferenceWorkflow +from flytekit.exceptions.user import FlyteValidationException +from flytekit.models.core import identifier as _identifier_model + + +def get_reference_entity( + resource_type: int, + project: str, + domain: str, + name: str, + version: str, + inputs: Dict[str, Type], + outputs: Dict[str, Type], +): + """ + See the documentation for :py:class:`flytekit.reference_task` and :py:class:`flytekit.reference_workflow` as well. + + This function is the general form of the two aforementioned functions. It's better for programmatic usage, as + the interface is passed in as arguments instead of analyzed from type annotations. + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_references.py + :start-after: # docs_ref_start + :end-before: # docs_ref_end + :language: python + :dedent: 4 + + :param resource_type: This is the type of entity it is. Must be one of + :py:class:`flytekit.models.core.identifier.ResourceType` + :param project: The project the entity you're looking for has been registered in. + :param domain: The domain the entity you're looking for has been registered in. + :param name: The name of the registered entity + :param version: The version the entity you're looking for has been registered with. + :param inputs: An ordered dictionary of input names as strings to their Python types. + :param outputs: An ordered dictionary of output names as strings to their Python types. + :return: + """ + if resource_type == _identifier_model.ResourceType.TASK: + return ReferenceTask(project, domain, name, version, inputs, outputs) + elif resource_type == _identifier_model.ResourceType.WORKFLOW: + return ReferenceWorkflow(project, domain, name, version, inputs, outputs) + elif resource_type == _identifier_model.ResourceType.LAUNCH_PLAN: + return ReferenceLaunchPlan(project, domain, name, version, inputs, outputs) + else: + raise FlyteValidationException("Resource type must be one of task, workflow, or launch plan") diff --git a/flytekit/flytekit/core/reference_entity.py b/flytekit/flytekit/core/reference_entity.py new file mode 100644 index 0000000000..0d861db513 --- /dev/null +++ b/flytekit/flytekit/core/reference_entity.py @@ -0,0 +1,266 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple, Type, Union + +from flytekit.core.context_manager import BranchEvalMode, ExecutionState, FlyteContext +from flytekit.core.interface import Interface, transform_interface_to_typed_interface +from flytekit.core.promise import ( + Promise, + VoidPromise, + create_and_link_node, + create_task_output, + extract_obj_name, + translate_inputs_to_literals, +) +from flytekit.core.type_engine import TypeEngine +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.core import identifier as _identifier_model +from flytekit.models.core import workflow as _workflow_model + + +@dataclass # type: ignore +class Reference(ABC): + project: str + domain: str + name: str + version: str + + def __post_init__(self): + self._id = _identifier_model.Identifier(self.resource_type, self.project, self.domain, self.name, self.version) + + @property + def id(self) -> _identifier_model.Identifier: + return self._id + + @property + @abstractmethod + def resource_type(self) -> int: + ... + + +@dataclass +class TaskReference(Reference): + """A reference object containing metadata that points to a remote task.""" + + @property + def resource_type(self) -> int: + return _identifier_model.ResourceType.TASK + + +@dataclass +class LaunchPlanReference(Reference): + """A reference object containing metadata that points to a remote launch plan.""" + + @property + def resource_type(self) -> int: + return _identifier_model.ResourceType.LAUNCH_PLAN + + +@dataclass +class WorkflowReference(Reference): + """A reference object containing metadata that points to a remote workflow.""" + + @property + def resource_type(self) -> int: + return _identifier_model.ResourceType.WORKFLOW + + +class ReferenceEntity(object): + def __init__( + self, + reference: Union[WorkflowReference, TaskReference, LaunchPlanReference], + inputs: Dict[str, Type], + outputs: Dict[str, Type], + ): + if ( + not isinstance(reference, WorkflowReference) + and not isinstance(reference, TaskReference) + and not isinstance(reference, LaunchPlanReference) + ): + raise Exception("Must be one of task, workflow, or launch plan") + self._reference = reference + self._native_interface = Interface(inputs=inputs, outputs=outputs) + self._interface = transform_interface_to_typed_interface(self._native_interface) + + def execute(self, **kwargs) -> Any: + raise Exception("Remote reference entities cannot be run locally. You must mock this out.") + + @property + def python_interface(self) -> Interface: + return self._native_interface + + @property + def interface(self) -> _interface_models.TypedInterface: + return self._interface + + @property + def reference(self) -> Reference: + return self._reference + + @property + def name(self): + return self._reference.id.name + + @property + def id(self) -> _identifier_model.Identifier: + return self.reference.id + + def unwrap_literal_map_and_execute( + self, ctx: FlyteContext, input_literal_map: _literal_models.LiteralMap + ) -> _literal_models.LiteralMap: + """ + Please see the implementation of the dispatch_execute function in the real task. + """ + + # Invoked before the task is executed + # Translate the input literals to Python native + native_inputs = TypeEngine.literal_map_to_kwargs(ctx, input_literal_map, self.python_interface.inputs) + + logger.info(f"Invoking {self.name} with inputs: {native_inputs}") + try: + native_outputs = self.execute(**native_inputs) + except Exception as e: + logger.exception(f"Exception when executing {e}") + raise e + logger.debug("Task executed successfully in user level") + + expected_output_names = list(self.python_interface.outputs.keys()) + if len(expected_output_names) == 1: + native_outputs_as_map = {expected_output_names[0]: native_outputs} + elif len(expected_output_names) == 0: + native_outputs_as_map = {} + else: + native_outputs_as_map = {expected_output_names[i]: native_outputs[i] for i, _ in enumerate(native_outputs)} + + # 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.interface.outputs[k].type + py_type = self.python_interface.outputs[k] + if isinstance(v, tuple): + raise AssertionError(f"Output({k}) in task{self.name} received a tuple {v}, instead of {py_type}") + literals[k] = TypeEngine.to_literal(ctx, v, py_type, literal_type) + outputs_literal_map = _literal_models.LiteralMap(literals=literals) + # After the execute has been successfully completed + return outputs_literal_map + + def local_execute(self, ctx: FlyteContext, **kwargs) -> Optional[Union[Tuple[Promise], Promise, VoidPromise]]: + """ + Please see the local_execute comments in the main task. + """ + # Unwrap the kwargs values. After this, we essentially have a LiteralMap + # The reason why we need to do this is because the inputs during local execute can be of 2 types + # - Promises or native constants + # Promises as essentially inputs from previous task executions + # native constants are just bound to this specific task (default values for a task input) + # Also alongwith 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, + native_types=self.python_interface.inputs, + ) + input_literal_map = _literal_models.LiteralMap(literals=kwargs) + + outputs_literal_map = self.unwrap_literal_map_and_execute(ctx, input_literal_map) + + # After running, we again have to wrap the outputs, if any, back into Promise objects + outputs_literals = outputs_literal_map.literals + output_names = list(self.python_interface.outputs.keys()) + if len(output_names) != len(outputs_literals): + # Length check, clean up exception + raise AssertionError(f"Length difference {len(output_names)} {len(outputs_literals)}") + + # Tasks that don't return anything still return a VoidPromise + if len(output_names) == 0: + return VoidPromise(self.name) + + vals = [Promise(var, outputs_literals[var]) for var in output_names] + return create_task_output(vals, self.python_interface) + + def local_execution_mode(self): + return ExecutionState.Mode.LOCAL_TASK_EXECUTION + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + return _workflow_model.NodeMetadata(name=extract_obj_name(self.name)) + + def compile(self, ctx: FlyteContext, *args, **kwargs): + return create_and_link_node(ctx, entity=self, **kwargs) + + def __call__(self, *args, **kwargs): + # When a Task is () aka __called__, there are three things we may do: + # a. Plain execution Mode - just run the execute function. If not overridden, we should raise an exception + # b. Compilation Mode - this happens when the function is called as part of a workflow (potentially + # dynamic task). Produce promise objects and create a node. + # c. Workflow Execution Mode - when a workflow is being run locally. Even though workflows are functions + # and everything should be able to be passed through naturally, we'll want to wrap output values of the + # function into objects, so that potential .with_cpu or other ancillary functions can be attached to do + # nothing. Subsequent tasks will have to know how to unwrap these. If by chance a non-Flyte task uses a + # task output as an input, things probably will fail pretty obviously. + # Since this is a reference entity, it still needs to be mocked otherwise an exception will be raised. + if len(args) > 0: + raise _user_exceptions.FlyteAssertion( + f"Cannot call reference entity with args - detected {len(args)} positional args {args}" + ) + + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None and ctx.compilation_state.mode == 1: + return self.compile(ctx, *args, **kwargs) + elif ctx.execution_state and ctx.execution_state.is_local_execution(): + if ctx.execution_state.branch_eval_mode == BranchEvalMode.BRANCH_SKIPPED: + return + return self.local_execute(ctx, **kwargs) + else: + logger.debug("Reference entity - running raw execute") + return self.execute(**kwargs) + + +# ReferenceEntity is not a registerable entity and therefore the below classes do not need to inherit from +# flytekit.models.common.FlyteIdlEntity. +class ReferenceTemplate(object): + def __init__(self, id: _identifier_model.Identifier, resource_type: int) -> None: + """ + A reference template encapsulates all the information necessary to use reference entities within other + workflows or dynamic tasks. + + :param flytekit.models.core.identifier.Identifier id: User-specified information that uniquely + identifies this reference. + :param int resource_type: The type of reference. See: flytekit.models.core.identifier.ResourceType + """ + self._id = id + self._resource_type = resource_type + + @property + def id(self) -> _identifier_model.Identifier: + """ + User-specified information that uniquely identifies this reference. + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._id + + @property + def resource_type(self) -> int: + """ + The type of reference. + :rtype: flytekit.models.core.identifier.ResourceType + """ + return self._resource_type + + +class ReferenceSpec(object): + def __init__(self, template: ReferenceTemplate) -> None: + """ + :param ReferenceTemplate template: + """ + self._template = template + + @property + def template(self) -> ReferenceTemplate: + """ + :rtype: ReferenceTemplate + """ + return self._template diff --git a/flytekit/flytekit/core/resources.py b/flytekit/flytekit/core/resources.py new file mode 100644 index 0000000000..2e8388f986 --- /dev/null +++ b/flytekit/flytekit/core/resources.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from typing import List, Optional + +from flytekit.models import task as task_models + + +@dataclass +class Resources(object): + """ + This class is used to specify both resource requests and resource limits. + + .. code-block:: python + + Resources(cpu="1", mem="2048") # This is 1 CPU and 2 KB of memory + Resources(cpu="100m", mem="2Gi") # This is 1/10th of a CPU and 2 gigabytes of memory + + # For Kubernetes-based tasks, pods use ephemeral local storage for scratch space, caching, and for logs. + # This allocates 1Gi of such local storage. + Resources(ephemeral_storage="1Gi") + + .. note:: + + Persistent storage is not currently supported on the Flyte backend. + + Please see the :std:ref:`User Guide ` for detailed examples. + Also refer to the `K8s conventions. `__ + """ + + cpu: Optional[str] = None + mem: Optional[str] = None + gpu: Optional[str] = None + ephemeral_storage: Optional[str] = None + + def __post_init__(self): + def _check_none_or_str(value): + if value is None: + return + if not isinstance(value, str): + raise AssertionError(f"{value} should be a string") + + _check_none_or_str(self.cpu) + _check_none_or_str(self.mem) + _check_none_or_str(self.gpu) + _check_none_or_str(self.ephemeral_storage) + + +@dataclass +class ResourceSpec(object): + requests: Resources + limits: Resources + + +_ResourceName = task_models.Resources.ResourceName +_ResourceEntry = task_models.Resources.ResourceEntry + + +def _convert_resources_to_resource_entries(resources: Resources) -> List[_ResourceEntry]: # type: ignore + resource_entries = [] + if resources.cpu is not None: + resource_entries.append(_ResourceEntry(name=_ResourceName.CPU, value=resources.cpu)) + if resources.mem is not None: + resource_entries.append(_ResourceEntry(name=_ResourceName.MEMORY, value=resources.mem)) + if resources.gpu is not None: + resource_entries.append(_ResourceEntry(name=_ResourceName.GPU, value=resources.gpu)) + if resources.ephemeral_storage is not None: + resource_entries.append(_ResourceEntry(name=_ResourceName.EPHEMERAL_STORAGE, value=resources.ephemeral_storage)) + return resource_entries + + +def convert_resources_to_resource_model( + requests: Optional[Resources] = None, + limits: Optional[Resources] = None, +) -> task_models.Resources: + """ + Convert flytekit ``Resources`` objects to a Resources model + + :param requests: Resource requests. Optional, defaults to ``None`` + :param limits: Resource limits. Optional, defaults to ``None`` + :return: The given resources as requests and limits + """ + request_entries = [] + limit_entries = [] + if requests is not None: + request_entries = _convert_resources_to_resource_entries(requests) + if limits is not None: + limit_entries = _convert_resources_to_resource_entries(limits) + return task_models.Resources(requests=request_entries, limits=limit_entries) diff --git a/flytekit/flytekit/core/schedule.py b/flytekit/flytekit/core/schedule.py new file mode 100644 index 0000000000..93116d0720 --- /dev/null +++ b/flytekit/flytekit/core/schedule.py @@ -0,0 +1,204 @@ +""" +.. autoclass:: flytekit.core.schedule.CronSchedule + :noindex: + +""" + +import datetime +import re as _re +from typing import Optional + +import croniter as _croniter + +from flytekit.models import schedule as _schedule_models + + +# Duplicates flytekit.common.schedules.Schedule to avoid using the ExtendedSdkType metaclass. +class CronSchedule(_schedule_models.Schedule): + """ + Use this when you have a launch plan that you want to run on a cron expression. + This uses standard `cron format `__ + in case where you are using default native scheduler using the schedule attribute. + + .. code-block:: + + CronSchedule( + schedule="*/1 * * * *", # Following schedule runs every min + ) + + See the :std:ref:`User Guide ` for further examples. + """ + + _VALID_CRON_ALIASES = [ + "hourly", + "hours", + "@hourly", + "daily", + "days", + "@daily", + "weekly", + "weeks", + "@weekly", + "monthly", + "months", + "@monthly", + "annually", + "@annually", + "yearly", + "years", + "@yearly", + ] + + # Not a perfect regex but good enough and simple to reason about + _OFFSET_PATTERN = _re.compile("([-+]?)P([-+0-9YMWD]+)?(T([-+0-9HMS.,]+)?)?") + + def __init__( + self, + cron_expression: Optional[str] = None, + schedule: Optional[str] = None, + offset: Optional[str] = None, + kickoff_time_input_arg: Optional[str] = None, + ): + """ + :param str cron_expression: This should be a cron expression in AWS style.Shouldn't be used in case of native scheduler. + :param str schedule: This takes a cron alias (see ``_VALID_CRON_ALIASES``) or a croniter parseable schedule. + Only one of this or ``cron_expression`` can be set, not both. This uses standard `cron format `_ + and is supported by native scheduler + :param str offset: + :param str kickoff_time_input_arg: This is a convenient argument to use when your code needs to know what time + a run was kicked off. Supply the name of the input argument of your workflow to this argument here. Note + that until Flyte has an atomic clock, there could be a few seconds here and there. That is, if your run is + supposed to kick off at 3pm UTC every Weds, it may actually be 15:00:02 or something. Example :: + + @workflow + def my_wf(kickoff_time: datetime): ... + + schedule = CronSchedule( + schedule="*/1 * * * *" + kickoff_time_input_arg="kickoff_time") + + """ + if cron_expression is None and schedule is None: + raise AssertionError("Either `cron_expression` or `schedule` should be specified.") + + if cron_expression is not None and offset is not None: + raise AssertionError("Only `schedule` is supported when specifying `offset`.") + + if cron_expression is not None: + CronSchedule._validate_expression(cron_expression) + + if schedule is not None: + CronSchedule._validate_schedule(schedule) + + if offset is not None: + CronSchedule._validate_offset(offset) + + super(CronSchedule, self).__init__( + kickoff_time_input_arg, + cron_expression=cron_expression, + cron_schedule=_schedule_models.Schedule.CronSchedule(schedule, offset) if schedule is not None else None, + ) + + @staticmethod + def _validate_expression(cron_expression: str): + """ + Ensures that the set value is a valid cron string. We use the format used in Cloudwatch and the best + explanation can be found here: + https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html#CronExpressions + :param str cron_expression: cron expression + """ + # We use the croniter lib to validate our cron expression. Since on the admin side we use Cloudwatch, + # we have a couple checks in order to line up Cloudwatch with Croniter. + tokens = cron_expression.split() + if len(tokens) != 6: + raise ValueError( + "Cron expression is invalid. A cron expression must have 6 fields. Cron expressions are in the " + "format of: `minute hour day-of-month month day-of-week year`. " + "Use `schedule` for 5 fields cron expression. Received: `{}`".format(cron_expression) + ) + + if tokens[2] != "?" and tokens[4] != "?": + raise ValueError( + "Scheduled string is invalid. A cron expression must have a '?' for either day-of-month or " + "day-of-week. Please specify '?' for one of those fields. Cron expressions are in the format of: " + "minute hour day-of-month month day-of-week year.\n\n" + "For more information: " + "https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html#CronExpressions" + ) + + try: + # Cut to 5 fields and just assume year field is good because croniter treats the 6th field as seconds. + # TODO: Parse this field ourselves and check + _croniter.croniter(" ".join(cron_expression.replace("?", "*").split()[:5])) + except Exception: + raise ValueError( + "Scheduled string is invalid. The cron expression was found to be invalid." + f" Provided cron expr: {cron_expression}" + ) + + @staticmethod + def _validate_schedule(schedule: str): + if schedule.lower() not in CronSchedule._VALID_CRON_ALIASES: + try: + _croniter.croniter(schedule) + except Exception: + raise ValueError( + "Schedule is invalid. It must be set to either a cron alias or valid cron expression." + f" Provided schedule: {schedule}" + ) + + @staticmethod + def _validate_offset(offset: str): + if CronSchedule._OFFSET_PATTERN.fullmatch(offset) is None: + raise ValueError("Offset is invalid. It must be an ISO 8601 duration. Provided offset: {}".format(offset)) + + +class FixedRate(_schedule_models.Schedule): + """ + Use this class to schedule a fixed-rate interval for a launch plan. + + .. code-block:: python + + from datetime import timedelta + + FixedRate(duration=timedelta(minutes=10)) + + See the :std:ref:`fixed rate intervals` chapter in the cookbook for additional usage examples. + """ + + def __init__(self, duration: datetime.timedelta, kickoff_time_input_arg: Optional[str] = None): + """ + :param datetime.timedelta duration: + :param str kickoff_time_input_arg: + """ + super(FixedRate, self).__init__(kickoff_time_input_arg, rate=self._translate_duration(duration)) + + @staticmethod + def _translate_duration(duration: datetime.timedelta): + """ + :param datetime.timedelta duration: timedelta between runs + :rtype: flytekit.models.schedule.Schedule.FixedRate + """ + _SECONDS_TO_MINUTES = 60 + _SECONDS_TO_HOURS = _SECONDS_TO_MINUTES * 60 + _SECONDS_TO_DAYS = _SECONDS_TO_HOURS * 24 + + if duration.microseconds != 0 or duration.seconds % _SECONDS_TO_MINUTES != 0: + raise AssertionError( + f"Granularity of less than a minute is not supported for FixedRate schedules. Received: {duration}" + ) + elif int(duration.total_seconds()) % _SECONDS_TO_DAYS == 0: + return _schedule_models.Schedule.FixedRate( + int(duration.total_seconds() / _SECONDS_TO_DAYS), + _schedule_models.Schedule.FixedRateUnit.DAY, + ) + elif int(duration.total_seconds()) % _SECONDS_TO_HOURS == 0: + return _schedule_models.Schedule.FixedRate( + int(duration.total_seconds() / _SECONDS_TO_HOURS), + _schedule_models.Schedule.FixedRateUnit.HOUR, + ) + else: + return _schedule_models.Schedule.FixedRate( + int(duration.total_seconds() / _SECONDS_TO_MINUTES), + _schedule_models.Schedule.FixedRateUnit.MINUTE, + ) diff --git a/flytekit/flytekit/core/shim_task.py b/flytekit/flytekit/core/shim_task.py new file mode 100644 index 0000000000..f96db3e49c --- /dev/null +++ b/flytekit/flytekit/core/shim_task.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import Any, Generic, Optional, Type, TypeVar, Union, cast + +from flytekit.core.context_manager import ExecutionParameters, ExecutionState, FlyteContext, FlyteContextManager +from flytekit.core.tracker import TrackedInstance +from flytekit.core.type_engine import TypeEngine +from flytekit.loggers import logger +from flytekit.models import dynamic_job as _dynamic_job +from flytekit.models import literals as _literal_models +from flytekit.models import task as _task_model + + +class ExecutableTemplateShimTask(object): + """ + The canonical ``@task`` decorated Python function task is pretty simple to reason about. At execution time (either + locally or on a Flyte cluster), the function runs. + + This class, along with the ``ShimTaskExecutor`` class below, represents another execution pattern. This pattern, + has two components: + + * The ``TaskTemplate``, or something like it like a ``FlyteTask``. + * An executor, which can use information from the task template (including the ``custom`` field) + + Basically at execution time (both locally and on a Flyte cluster), the task template is given to the executor, + which is responsible for computing and returning the results. + + .. note:: + + The interface at execution time will have to derived from the Flyte IDL interface, which means it may be lossy. + This is because when a task is serialized from Python into the ``TaskTemplate`` some information is lost because + Flyte IDL can't keep track of every single Python type (or Java type if writing in the Java flytekit). + + This class also implements the ``dispatch_execute`` and ``execute`` functions to make it look like a ``PythonTask`` + that the ``entrypoint.py`` can execute, even though this class doesn't inherit from ``PythonTask``. + """ + + def __init__(self, tt: _task_model.TaskTemplate, executor_type: Type[ShimTaskExecutor], *args, **kwargs): + self._executor_type = executor_type + self._executor = executor_type() + self._task_template = tt + super().__init__(*args, **kwargs) + + @property + def name(self) -> str: + """Return the name of the underlying task.""" + if self._task_template is not None: + return self._task_template.id.name + # if not access the subclass's name + return self._name # type: ignore + + @property + def task_template(self) -> _task_model.TaskTemplate: + return self._task_template + + @property + def executor(self) -> ShimTaskExecutor: + return self._executor + + @property + def executor_type(self) -> Type[ShimTaskExecutor]: + return self._executor_type + + def execute(self, **kwargs) -> Any: + """ + Rather than running here, send everything to the executor. + """ + return self.executor.execute_from_model(self.task_template, **kwargs) + + def pre_execute(self, user_params: Optional[ExecutionParameters]) -> Optional[ExecutionParameters]: + """ + This function is a stub, just here to keep dispatch_execute compatibility between this class and PythonTask. + """ + return user_params + + def post_execute(self, _: Optional[ExecutionParameters], rval: Any) -> Any: + """ + This function is a stub, just here to keep dispatch_execute compatibility between this class and PythonTask. + """ + return rval + + def dispatch_execute( + self, ctx: FlyteContext, input_literal_map: _literal_models.LiteralMap + ) -> Union[_literal_models.LiteralMap, _dynamic_job.DynamicJobSpec]: + """ + This function is largely similar to the base PythonTask, with the exception that we have to infer the Python + interface before executing. Also, we refer to ``self.task_template`` rather than just ``self`` similar to task + classes that derive from the base ``PythonTask``. + """ + # Invoked before the task is executed + new_user_params = self.pre_execute(ctx.user_space_params) + + # Create another execution context with the new user params, but let's keep the same working dir + with FlyteContextManager.with_context( + ctx.with_execution_state( + cast(ExecutionState, ctx.execution_state).with_params(user_space_params=new_user_params) + ) + ) as exec_ctx: + # Added: Have to reverse the Python interface from the task template Flyte interface + # See docstring for more details. + guessed_python_input_types = TypeEngine.guess_python_types(self.task_template.interface.inputs) + native_inputs = TypeEngine.literal_map_to_kwargs(exec_ctx, input_literal_map, guessed_python_input_types) + + logger.info(f"Invoking FlyteTask executor {self.task_template.id.name} with inputs: {native_inputs}") + try: + native_outputs = self.execute(**native_inputs) + except Exception as e: + logger.exception(f"Exception when executing {e}") + raise e + + logger.debug("Task executed successfully in user level") + # Lets run the post_execute method. This may result in a IgnoreOutputs Exception, which is + # bubbled up to be handled at the callee layer. + native_outputs = self.post_execute(new_user_params, native_outputs) + + # Short circuit the translation to literal map because what's returned may be a dj spec (or an + # already-constructed LiteralMap if the dynamic task was a no-op), not python native values + if isinstance(native_outputs, _literal_models.LiteralMap) or isinstance( + native_outputs, _dynamic_job.DynamicJobSpec + ): + return native_outputs + + expected_output_names = list(self.task_template.interface.outputs.keys()) + if len(expected_output_names) == 1: + # Here we have to handle the fact that the task could've been declared with a typing.NamedTuple of + # length one. That convention is used for naming outputs - and single-length-NamedTuples are + # particularly troublesome but elegant handling of them is not a high priority + # Again, we're using the output_tuple_name as a proxy. + # Deleted some stuff + native_outputs_as_map = {expected_output_names[0]: native_outputs} + elif len(expected_output_names) == 0: + native_outputs_as_map = {} + else: + native_outputs_as_map = { + expected_output_names[i]: native_outputs[i] for i, _ in enumerate(native_outputs) + } + + # 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.task_template.interface.outputs[k].type + py_type = type(v) + + if isinstance(v, tuple): + raise AssertionError( + f"Output({k}) in task{self.task_template.id.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: + raise AssertionError(f"failed to convert return value for var {k}") from e + + outputs_literal_map = _literal_models.LiteralMap(literals=literals) + # After the execute has been successfully completed + return outputs_literal_map + + +T = TypeVar("T") + + +class ShimTaskExecutor(TrackedInstance, Generic[T]): + def execute_from_model(self, tt: _task_model.TaskTemplate, **kwargs) -> Any: + """ + This function must be overridden and is where all the business logic for running a task should live. Keep in + mind that you're only working with the ``TaskTemplate``. You won't have access to any information in the task + that wasn't serialized into the template. + + :param tt: This is the template, the serialized form of the task. + :param kwargs: These are the Python native input values to the task. + :return: Python native output values from the task. + """ + raise NotImplementedError diff --git a/flytekit/flytekit/core/task.py b/flytekit/flytekit/core/task.py new file mode 100644 index 0000000000..a99fbf599e --- /dev/null +++ b/flytekit/flytekit/core/task.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import datetime as _datetime +from functools import update_wrapper +from typing import Any, Callable, Dict, Iterable, List, Optional, Type, TypeVar, Union, overload + +from flytekit.core import launch_plan as _annotated_launchplan +from flytekit.core import workflow as _annotated_workflow +from flytekit.core.base_task import TaskMetadata, TaskResolverMixin +from flytekit.core.interface import transform_function_to_interface +from flytekit.core.pod_template import PodTemplate +from flytekit.core.python_function_task import PythonFunctionTask +from flytekit.core.reference_entity import ReferenceEntity, TaskReference +from flytekit.core.resources import Resources +from flytekit.extras.accelerators import BaseAccelerator +from flytekit.image_spec.image_spec import ImageSpec +from flytekit.models.documentation import Documentation +from flytekit.models.security import Secret + + +class TaskPlugins(object): + """ + This is the TaskPlugins factory for task types that are derivative of PythonFunctionTask. + Every task that the user wishes to use should be available in this factory. + Usage + + .. code-block:: python + + TaskPlugins.register_pythontask_plugin(config_object_type, plugin_object_type) + # config_object_type is any class that will be passed to the plugin_object as task_config + # Plugin_object_type is a derivative of ``PythonFunctionTask`` + + Examples of available task plugins include different query-based plugins such as + :py:class:`flytekitplugins.athena.task.AthenaTask` and :py:class:`flytekitplugins.hive.task.HiveTask`, ML tools like + :py:class:`plugins.awssagemaker.flytekitplugins.awssagemaker.training.SagemakerBuiltinAlgorithmsTask`, kubeflow + operators like :py:class:`plugins.kfpytorch.flytekitplugins.kfpytorch.task.PyTorchFunctionTask` and + :py:class:`plugins.kftensorflow.flytekitplugins.kftensorflow.task.TensorflowFunctionTask`, and generic plugins like + :py:class:`flytekitplugins.pod.task.PodFunctionTask` which doesn't integrate with third party tools or services. + + The `task_config` is different for every task plugin type. This is filled out by users when they define a task to + specify plugin-specific behavior and features. For example, with a query type task plugin, the config might store + information related to which database to query. + + The `plugin_object_type` can be used to customize execution behavior and task serialization properties in tandem + with the `task_config`. + """ + + _PYTHONFUNCTION_TASK_PLUGINS: Dict[type, Type[PythonFunctionTask]] = {} + + @classmethod + def register_pythontask_plugin(cls, plugin_config_type: type, plugin: Type[PythonFunctionTask]): + """ + Use this method to register a new plugin into Flytekit. Usage :: + + .. code-block:: python + + TaskPlugins.register_pythontask_plugin(config_object_type, plugin_object_type) + # config_object_type is any class that will be passed to the plugin_object as task_config + # Plugin_object_type is a derivative of ``PythonFunctionTask`` + """ + if plugin_config_type in cls._PYTHONFUNCTION_TASK_PLUGINS: + found = cls._PYTHONFUNCTION_TASK_PLUGINS[plugin_config_type] + if found == plugin: + return + raise TypeError( + f"Requesting to register plugin {plugin} - collides with existing plugin {found}" + f" for type {plugin_config_type}" + ) + + cls._PYTHONFUNCTION_TASK_PLUGINS[plugin_config_type] = plugin + + @classmethod + def find_pythontask_plugin(cls, plugin_config_type: type) -> Type[PythonFunctionTask]: + """ + Returns a PluginObjectType if found or returns the base PythonFunctionTask + """ + if plugin_config_type in cls._PYTHONFUNCTION_TASK_PLUGINS: + return cls._PYTHONFUNCTION_TASK_PLUGINS[plugin_config_type] + # Defaults to returning Base PythonFunctionTask + return PythonFunctionTask + + +T = TypeVar("T") +FuncOut = TypeVar("FuncOut") + + +@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 = ..., + node_dependency_hints: Optional[ + Iterable[Union[PythonFunctionTask, _annotated_launchplan.LaunchPlan, _annotated_workflow.WorkflowBase]] + ] = ..., + task_resolver: Optional[TaskResolverMixin] = ..., + docs: Optional[Documentation] = ..., + disable_deck: Optional[bool] = ..., + enable_deck: Optional[bool] = ..., + pod_template: Optional["PodTemplate"] = ..., + pod_template_name: Optional[str] = ..., + accelerator: Optional[BaseAccelerator] = ..., +) -> Callable[[Callable[..., FuncOut]], PythonFunctionTask[T]]: + ... + + +@overload +def task( + _task_function: Callable[..., FuncOut], + 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 = ..., + node_dependency_hints: Optional[ + Iterable[Union[PythonFunctionTask, _annotated_launchplan.LaunchPlan, _annotated_workflow.WorkflowBase]] + ] = ..., + task_resolver: Optional[TaskResolverMixin] = ..., + docs: Optional[Documentation] = ..., + disable_deck: Optional[bool] = ..., + enable_deck: Optional[bool] = ..., + pod_template: Optional["PodTemplate"] = ..., + pod_template_name: Optional[str] = ..., + accelerator: Optional[BaseAccelerator] = ..., +) -> Union[PythonFunctionTask[T], Callable[..., FuncOut]]: + ... + + +def task( + _task_function: Optional[Callable[..., FuncOut]] = None, + task_config: Optional[T] = None, + cache: bool = False, + cache_serialize: bool = False, + cache_version: str = "", + retries: int = 0, + interruptible: Optional[bool] = None, + deprecated: str = "", + timeout: Union[_datetime.timedelta, int] = 0, + container_image: Optional[Union[str, ImageSpec]] = None, + environment: Optional[Dict[str, str]] = None, + requests: Optional[Resources] = None, + limits: Optional[Resources] = None, + secret_requests: Optional[List[Secret]] = None, + execution_mode: PythonFunctionTask.ExecutionBehavior = PythonFunctionTask.ExecutionBehavior.DEFAULT, + node_dependency_hints: Optional[ + Iterable[Union[PythonFunctionTask, _annotated_launchplan.LaunchPlan, _annotated_workflow.WorkflowBase]] + ] = None, + task_resolver: Optional[TaskResolverMixin] = None, + docs: Optional[Documentation] = None, + disable_deck: Optional[bool] = None, + enable_deck: Optional[bool] = None, + pod_template: Optional["PodTemplate"] = None, + pod_template_name: Optional[str] = None, + accelerator: Optional[BaseAccelerator] = None, +) -> Union[Callable[[Callable[..., FuncOut]], PythonFunctionTask[T]], PythonFunctionTask[T], Callable[..., FuncOut]]: + """ + This is the core decorator to use for any task type in flytekit. + + Tasks are the building blocks of Flyte. They represent users code. Tasks have the following properties + + * Versioned (usually tied to the git revision SHA1) + * Strong interfaces (specified inputs and outputs) + * Declarative + * Independently executable + * Unit testable + + For a simple python task, + + .. code-block:: python + + @task + def my_task(x: int, y: typing.Dict[str, str]) -> str: + ... + + For specific task types + + .. code-block:: python + + @task(task_config=Spark(), retries=3) + def my_task(x: int, y: typing.Dict[str, str]) -> str: + ... + + Please see some cookbook :std:ref:`task examples ` for additional information. + + :param _task_function: This argument is implicitly passed and represents the decorated function + :param task_config: This argument provides configuration for a specific task types. + Please refer to the plugins documentation for the right object to use. + :param cache: Boolean that indicates if caching should be enabled + :param cache_serialize: Boolean that indicates if identical (ie. same inputs) instances of this task should be + executed in serial when caching is enabled. This means that given multiple concurrent executions over + identical inputs, only a single instance executes and the rest wait to reuse the cached results. This + parameter does nothing without also setting the cache parameter. + :param cache_version: Cache version to use. Changes to the task signature will automatically trigger a cache miss, + but you can always manually update this field as well to force a cache miss. You should also manually bump + this version if the function body/business logic has changed, but the signature hasn't. + :param retries: Number of times to retry this task during a workflow execution. + :param interruptible: [Optional] Boolean that indicates that this task can be interrupted and/or scheduled on nodes + with lower QoS guarantees. This will directly reduce the `$`/`execution cost` associated, + at the cost of performance penalties due to potential interruptions. Requires additional + Flyte platform level configuration. If no value is provided, the task will inherit this + attribute from its workflow, as follows: + No values set for interruptible at the task or workflow level - task is not interruptible + Task has interruptible=True, but workflow has no value set - task is interruptible + Workflow has interruptible=True, but task has no value set - task is interruptible + Workflow has interruptible=False, but task has interruptible=True - task is interruptible + Workflow has interruptible=True, but task has interruptible=False - task is not interruptible + :param deprecated: A string that can be used to provide a warning message for deprecated task. Absence / empty str + indicates that the task is active and not deprecated + :param timeout: the max amount of time for which one execution of this task should be executed for. The execution + will be terminated if the runtime exceeds the given timeout (approximately). + :param container_image: By default the configured FLYTE_INTERNAL_IMAGE is used for every task. This directive can be + used to provide an alternate image for a specific task. This is useful for the cases in which images + bloat because of various dependencies and a dependency is only required for this or a set of tasks, + and they vary from the default. + + .. code-block:: python + + # Use default image name `fqn` and alter the tag to `tag-{{default.tag}}` tag of the default image + # with a prefix. In this case, it is assumed that the image like + # flytecookbook:tag-gitsha is published alongwith the default of flytecookbook:gitsha + @task(container_image='{{.images.default.fqn}}:tag-{{images.default.tag}}') + def foo(): + ... + + # Refer to configurations to configure fqns for other images besides default. In this case it will + # lookup for an image named xyz + @task(container_image='{{.images.xyz.fqn}}:{{images.default.tag}}') + def foo2(): + ... + :param environment: Environment variables that should be added for this tasks execution + :param requests: Specify compute resource requests for your task. For Pod-plugin tasks, these values will apply only + to the primary container. + :param limits: Compute limits. Specify compute resource limits for your task. For Pod-plugin tasks, these values + will apply only to the primary container. For more information, please see :py:class:`flytekit.Resources`. + :param secret_requests: Keys that can identify the secrets supplied at runtime. Ideally the secret keys should also be + semi-descriptive. The key values will be available from runtime, if the backend is configured + to provide secrets and if secrets are available in the configured secrets store. + Possible options for secret stores are - Vault, Confidant, Kube secrets, AWS KMS etc + Refer to :py:class:`Secret` to understand how to specify the request for a secret. It + may change based on the backend provider. + :param execution_mode: This is mainly for internal use. Please ignore. It is filled in automatically. + :param node_dependency_hints: A list of tasks, launchplans, or workflows that this task depends on. This is only + for dynamic tasks/workflows, where flyte cannot automatically determine the dependencies prior to runtime. + Even on dynamic tasks this is optional, but in some scenarios it will make registering the workflow easier, + because it allows registration to be done the same as for static tasks/workflows. + + For example this is useful to run launchplans dynamically, because launchplans must be registered on flyteadmin + before they can be run. Tasks and workflows do not have this requirement. + + .. code-block:: python + + @workflow + def workflow0(): + ... + + launchplan0 = LaunchPlan.get_or_create(workflow0) + + # Specify node_dependency_hints so that launchplan0 will be registered on flyteadmin, despite this being a + # dynamic task. + @dynamic(node_dependency_hints=[launchplan0]) + def launch_dynamically(): + # To run a sub-launchplan it must have previously been registered on flyteadmin. + return [launchplan0]*10 + :param task_resolver: Provide a custom task resolver. + :param disable_deck: (deprecated) If true, this task will not output deck html file + :param enable_deck: If true, this task will output deck html file + :param docs: Documentation about this task + :param pod_template: Custom PodTemplate for this task. + :param pod_template_name: The name of the existing PodTemplate resource which will be used in this task. + :param accelerator: The accelerator to use for this task. + """ + + def wrapper(fn: Callable[..., Any]) -> PythonFunctionTask[T]: + _metadata = TaskMetadata( + cache=cache, + cache_serialize=cache_serialize, + cache_version=cache_version, + retries=retries, + interruptible=interruptible, + deprecated=deprecated, + timeout=timeout, + ) + + task_instance = TaskPlugins.find_pythontask_plugin(type(task_config))( + task_config, + fn, + metadata=_metadata, + container_image=container_image, + environment=environment, + requests=requests, + limits=limits, + secret_requests=secret_requests, + execution_mode=execution_mode, + node_dependency_hints=node_dependency_hints, + task_resolver=task_resolver, + disable_deck=disable_deck, + enable_deck=enable_deck, + docs=docs, + pod_template=pod_template, + pod_template_name=pod_template_name, + accelerator=accelerator, + ) + update_wrapper(task_instance, fn) + return task_instance + + if _task_function: + return wrapper(_task_function) + else: + return wrapper + + +class ReferenceTask(ReferenceEntity, PythonFunctionTask): # type: ignore + """ + This is a reference task, the body of the function passed in through the constructor will never be used, only the + signature of the function will be. The signature should also match the signature of the task you're referencing, + as stored by Flyte Admin, if not, workflows using this will break upon compilation. + """ + + def __init__( + self, project: str, domain: str, name: str, version: str, inputs: Dict[str, type], outputs: Dict[str, Type] + ): + super().__init__(TaskReference(project, domain, name, version), inputs, outputs) + + # Reference tasks shouldn't call the parent constructor, but the parent constructor is what sets the resolver + self._task_resolver = None # type: ignore + + +def reference_task( + project: str, + domain: str, + name: str, + version: str, +) -> Callable[[Callable[..., Any]], ReferenceTask]: + """ + A reference task is a pointer to a task that already exists on your Flyte installation. This + object will not initiate a network call to Admin, which is why the user is asked to provide the expected interface. + If at registration time the interface provided causes an issue with compilation, an error will be returned. + + Example: + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_references.py + :pyobject: ref_t1 + + """ + + def wrapper(fn) -> ReferenceTask: + interface = transform_function_to_interface(fn) + return ReferenceTask(project, domain, name, version, interface.inputs, interface.outputs) + + return wrapper diff --git a/flytekit/flytekit/core/testing.py b/flytekit/flytekit/core/testing.py new file mode 100644 index 0000000000..f1a0fec7de --- /dev/null +++ b/flytekit/flytekit/core/testing.py @@ -0,0 +1,79 @@ +import typing +from contextlib import contextmanager +from typing import Union +from unittest.mock import MagicMock + +from flytekit.core.base_task import PythonTask +from flytekit.core.reference_entity import ReferenceEntity +from flytekit.core.workflow import WorkflowBase +from flytekit.loggers import logger + + +@contextmanager +def task_mock(t: PythonTask) -> typing.Generator[MagicMock, None, None]: + """ + Use this method to mock a task declaration. It can mock any Task in Flytekit as long as it has a python native + interface associated with it. + + The returned object is a MagicMock and allows to perform all such methods. This MagicMock, mocks the execute method + on the PythonTask + + Usage: + + .. code-block:: python + + @task + def t1(i: int) -> int: + pass + + with task_mock(t1) as m: + m.side_effect = lambda x: x + t1(10) + # The mock is valid only within this context + """ + + if not isinstance(t, PythonTask) and not isinstance(t, WorkflowBase) and not isinstance(t, ReferenceEntity): + raise Exception("Can only be used for tasks") + + m = MagicMock() + + def _log(*args, **kwargs): + logger.warning(f"Invoking mock method for task: '{t.name}'") + return m(*args, **kwargs) + + _captured_fn = t.execute + t.execute = _log # type: ignore + yield m + t.execute = _captured_fn # type: ignore + + +def patch(target: Union[PythonTask, WorkflowBase, ReferenceEntity]): + """ + This is a decorator used for testing. + """ + if ( + not isinstance(target, PythonTask) + and not isinstance(target, WorkflowBase) + and not isinstance(target, ReferenceEntity) + ): + raise Exception("Can only use mocks on tasks/workflows declared in Python.") + + logger.info( + "When using this patch function on Flyte entities, please be aware weird issues may arise if also" + "using mock.patch on internal Flyte classes like PythonFunctionWorkflow. See" + "https://github.com/flyteorg/flyte/issues/854 for more information" + ) + + def wrapper(test_fn): + def new_test(*args, **kwargs): + logger.warning(f"Invoking mock method for target: '{target.name}'") + m = MagicMock() + saved = target.execute + target.execute = m + results = test_fn(m, *args, **kwargs) + target.execute = saved + return results + + return new_test + + return wrapper diff --git a/flytekit/flytekit/core/tracked_abc.py b/flytekit/flytekit/core/tracked_abc.py new file mode 100644 index 0000000000..3c39d3725c --- /dev/null +++ b/flytekit/flytekit/core/tracked_abc.py @@ -0,0 +1,11 @@ +from abc import ABC + +from flytekit.core.tracker import TrackedInstance + + +class FlyteTrackedABC(type(TrackedInstance), type(ABC)): # type: ignore + """ + This class exists because if you try to inherit from abc.ABC and TrackedInstance by itself, you'll get the + well-known ``TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass + of the metaclasses of all its bases`` error. + """ diff --git a/flytekit/flytekit/core/tracker.py b/flytekit/flytekit/core/tracker.py new file mode 100644 index 0000000000..24ac0ffd06 --- /dev/null +++ b/flytekit/flytekit/core/tracker.py @@ -0,0 +1,349 @@ +import importlib +import inspect +import os +import sys +import typing +from pathlib import Path +from types import ModuleType +from typing import Callable, Optional, Tuple, Union + +from flytekit.configuration.feature_flags import FeatureFlags +from flytekit.exceptions import system as _system_exceptions +from flytekit.loggers import logger + + +def import_module_from_file(module_name, file): + try: + spec = importlib.util.spec_from_file_location(module_name, file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + except AssertionError: + # handle where we can't determine the module of functions within the module + return importlib.import_module(module_name) + except Exception as exc: + raise ModuleNotFoundError(f"Module from file {file} cannot be loaded") from exc + + +class InstanceTrackingMeta(type): + """ + Please see the original class :py:class`flytekit.common.mixins.registerable._InstanceTracker` also and also look + at the tests in the ``tests/flytekit/unit/core/tracker/test_tracking/`` folder to see how it's used. + + Basically, this will make instances of classes that use this metaclass aware of the module (the .py file) that + caused the instance to be created. This is useful because it means that we can then (at least try to) find the + variable that the instance was assigned to. + """ + + @staticmethod + def _get_module_from_main(globals) -> Optional[str]: + curdir = Path.cwd() + file = globals.get("__file__") + if file is None: + return None + + file = Path(file) + try: + file_relative = file.relative_to(curdir) + except ValueError: + return None + + module_components = [*file_relative.with_suffix("").parts] + module_name = ".".join(module_components) + if len(module_components) == 0: + return None + + # make sure current directory is in the PYTHONPATH. + sys.path.insert(0, str(curdir)) + try: + return import_module_from_file(module_name, file) + except ModuleNotFoundError: + return None + + @staticmethod + def _find_instance_module(): + frame = inspect.currentframe() + while frame: + if frame.f_code.co_name == "" and "__name__" in frame.f_globals: + if frame.f_globals["__name__"] != "__main__": + return frame.f_globals["__name__"], None + + # Try to find the module and filename in the case that we're in the __main__ module + # This is useful in cases that use FlyteRemote to load tasks/workflows that are defined + # in the same file as where FlyteRemote is being invoked to register and execute Flyte + # entities. One such case is with the `eager` decorator in the flytekit.experimental module. + mod = InstanceTrackingMeta._get_module_from_main(frame.f_globals) + if mod is None: + return None, None + + # This is used in find_lhs to find the trackable instances when the module is loaded from the __file__. + return mod.__name__, mod.__file__ + frame = frame.f_back + return None, None + + def __call__(cls, *args, **kwargs): + o = super(InstanceTrackingMeta, cls).__call__(*args, **kwargs) + mod_name, mod_file = InstanceTrackingMeta._find_instance_module() + o._instantiated_in = mod_name + o._module_file = mod_file + return o + + +class TrackedInstance(metaclass=InstanceTrackingMeta): + """ + Please see the notes for the metaclass above first. + + This functionality has two use-cases currently, + * Keep track of naming for non-function ``PythonAutoContainerTasks``. That is, things like the + :py:class:`flytekit.extras.sqlite3.task.SQLite3Task` task. + * Task resolvers, because task resolvers are instances of :py:class:`flytekit.core.python_auto_container.TaskResolverMixin` + classes, not the classes themselves, which means we need to look on the left hand side of them to see how to + find them at task execution time. + """ + + def __init__(self, *args, **kwargs): + self._instantiated_in = None + self._module_file = None + self._lhs = None + super().__init__(*args, **kwargs) + + @property + def instantiated_in(self) -> str: + return self._instantiated_in + + @property + def location(self) -> str: + n, _, _, _ = extract_task_module(self) + return n + + @property + def lhs(self): + if self._lhs is not None: + return self._lhs + return self.find_lhs() + + def find_lhs(self) -> str: + if self._lhs is not None: + return self._lhs + + if self._instantiated_in is None or self._instantiated_in == "": + raise _system_exceptions.FlyteSystemException(f"Object {self} does not have an _instantiated in") + + logger.debug(f"Looking for LHS for {self} from {self._instantiated_in}") + m = importlib.import_module(self._instantiated_in) + for k in dir(m): + try: + if getattr(m, k) is self: + logger.debug(f"Found LHS for {self}, {k}") + self._lhs = k + return k + except ValueError as err: + # Empty pandas dataframes behave weirdly here such that calling `m.df` raises: + # ValueError: The truth value of a {type(self).__name__} is ambiguous. Use a.empty, a.bool(), a.item(), + # a.any() or a.all() + # 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)) + + # Try to find object in module when the tracked instance is defined in the __main__ module. + # This section tries to find the matching object in the module when the module is loaded from the __file__. + if self._module_file is not None: + # Since the module loaded from the file is different from the original module that defined self, we need + # to match by variable name and type. + module = import_module_from_file(self._instantiated_in, self._module_file) + + def _candidate_name_matches(candidate) -> bool: + if not hasattr(candidate, "name") or not hasattr(self, "name"): + return False + return candidate.name == self.name + + for k in dir(module): + try: + candidate = getattr(module, k) + # consider the variable equivalent to self if it's of the same type and name + if ( + type(candidate) == type(self) + and _candidate_name_matches(candidate) + and candidate.instantiated_in == self.instantiated_in + ): + self._lhs = k + return k + except ValueError as err: + logger.warning(f"Caught ValueError {err} while attempting to auto-assign name") + + 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}") + + +def isnested(func: Callable) -> bool: + """ + Returns true if a function is local to another function and is not accessible through a module + + This would essentially be any function with a `..` (defined within a function) e.g. + + .. code:: python + + def foo(): + def foo_inner(): + pass + pass + + In the above example `foo_inner` is the local function or a nested function. + """ + + return hasattr(func, "__code__") and (func.__code__.co_flags & inspect.CO_NESTED != 0) + + +def is_functools_wrapped_module_level(func: Callable) -> bool: + """Returns true if the function is a functools.wraps-updated function that is defined in the module-level scope. + + .. code:: python + + import functools + + def decorator(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + return fn(*arks, **kwargs) + + return wrapper + + @decorator + def foo(): + ... + + def define_inner_wrapped_fn(): + + @decorator + def foo_inner(*args, **kwargs): + return fn(*arks, **kwargs) + + return foo_inner + + bar = define_inner_wrapped_fn() + + is_functools_wrapped_module_level(foo) # True + is_functools_wrapped_module_level(bar) # False + + In this case, applying this function to ``foo`` returns true because ``foo`` was defined in the module-level scope. + Applying this function to ``bar`` returns false because it's being assigned to ``foo_inner``, which is a + functools-wrapped function but is actually defined in the local scope of ``define_inner_wrapped_fn``. + + This works because functools.wraps updates the __name__ and __qualname__ attributes of the wrapper to match the + wrapped function. Since ``define_inner_wrapped_fn`` doesn't update the __qualname__ of ``foo_inner``, the inner + function's __qualname__ won't match its __name__. + """ + return hasattr(func, "__wrapped__") and func.__name__ == func.__qualname__ + + +def istestfunction(func) -> bool: + """ + Return true if the function is defined in a test module. + + A test module has to have `test_` as the prefix or `_test` as the suffix. + False in all other cases. + """ + mod = inspect.getmodule(func) + if mod: + mod_name = mod.__name__ + if "." in mod_name: + mod_name = mod_name.split(".")[-1] + return mod_name.startswith("test_") or mod_name.endswith("_test") + return False + + +class _ModuleSanitizer(object): + """ + Sanitizes and finds the absolute module path irrespective of the import location. + """ + + def __init__(self): + self._module_cache = {} + + def _resolve_abs_module_name(self, path: str, package_root: typing.Optional[str] = None) -> str: + """ + Recursively finds the root python package under-which basename exists + """ + # If we have already computed the module for this directory - return + if path in self._module_cache: + return self._module_cache[path] + + basename = os.path.basename(path) + dirname = os.path.dirname(path) + + # Let us remove any extensions like .py + basename = os.path.splitext(basename)[0] + + if dirname == package_root: + return basename + + # If we have reached a directory with no __init__, ignore + if "__init__.py" not in os.listdir(dirname): + return basename + + # Now recurse down such that we can extract the absolute module path + mod_name = self._resolve_abs_module_name(dirname, package_root) + final_mod_name = f"{mod_name}.{basename}" if mod_name else basename + self._module_cache[path] = final_mod_name + return final_mod_name + + def get_absolute_module_name(self, path: str, package_root: typing.Optional[str] = None) -> str: + """ + Returns the absolute module path for a given python file path. This assumes that every module correctly contains + a __init__.py file. Absence of this file, indicates the root. + """ + return self._resolve_abs_module_name(path, package_root) + + +_mod_sanitizer = _ModuleSanitizer() + + +def _task_module_from_callable(f: Callable): + mod = inspect.getmodule(f) + mod_name = getattr(mod, "__name__", f.__module__) + name = f.__name__.split(".")[-1] + return mod, mod_name, name + + +def extract_task_module(f: Union[Callable, TrackedInstance]) -> Tuple[str, str, str, str]: + """ + Returns the task-name, absolute module and the string name of the callable. + :param f: A task or any other callable + :return: [name to use: str, module_name: str, function_name: str, full_path: str] + """ + + if isinstance(f, TrackedInstance): + if hasattr(f, "task_function"): + mod, mod_name, name = _task_module_from_callable(f.task_function) + elif f.instantiated_in: + mod = importlib.import_module(f.instantiated_in) + mod_name = mod.__name__ + name = f.lhs + else: + raise AssertionError(f"Unable to determine module of {f}") + else: + mod, mod_name, name = _task_module_from_callable(f) + + if mod is None: + raise AssertionError(f"Unable to determine module of {f}") + + if mod_name == "__main__": + if hasattr(f, "task_function"): + f = f.task_function + inspect_file = inspect.getfile(f) # type: ignore + return name, "", name, os.path.abspath(inspect_file) + + mod_name = get_full_module_path(mod, mod_name) + return f"{mod_name}.{name}", mod_name, name, os.path.abspath(inspect.getfile(mod)) + + +def get_full_module_path(mod: ModuleType, mod_name: str) -> str: + if FeatureFlags.FLYTE_PYTHON_PACKAGE_ROOT != ".": + package_root = ( + FeatureFlags.FLYTE_PYTHON_PACKAGE_ROOT if FeatureFlags.FLYTE_PYTHON_PACKAGE_ROOT != "auto" else None + ) + new_mod_name = _mod_sanitizer.get_absolute_module_name(inspect.getabsfile(mod), package_root) + # We only replace the mod_name if it is more specific, else we already have a fully resolved path + if len(new_mod_name) > len(mod_name): + mod_name = new_mod_name + return mod_name diff --git a/flytekit/flytekit/core/type_engine.py b/flytekit/flytekit/core/type_engine.py new file mode 100644 index 0000000000..76f750233b --- /dev/null +++ b/flytekit/flytekit/core/type_engine.py @@ -0,0 +1,2094 @@ +from __future__ import annotations + +import collections +import copy +import dataclasses +import datetime as _datetime +import enum +import inspect +import json +import json as _json +import mimetypes +import textwrap +import typing +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import Dict, List, NamedTuple, Optional, Type, cast + +from dataclasses_json import DataClassJsonMixin, dataclass_json +from google.protobuf import json_format as _json_format +from google.protobuf import struct_pb2 as _struct +from google.protobuf.json_format import MessageToDict as _MessageToDict +from google.protobuf.json_format import ParseDict as _ParseDict +from google.protobuf.message import Message +from google.protobuf.struct_pb2 import Struct +from marshmallow_enum import EnumField, LoadDumpOptions +from mashumaro.mixins.json import DataClassJSONMixin +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.interaction.string_literals import literal_map_string_repr +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 +from flytekit.models.annotation import TypeAnnotation as TypeAnnotationModel +from flytekit.models.core import types as _core_types +from flytekit.models.literals import ( + Blob, + BlobMetadata, + Literal, + LiteralCollection, + LiteralMap, + Primitive, + Scalar, + Schema, + StructuredDatasetMetadata, + Union, + Void, +) +from flytekit.models.types import LiteralType, SimpleType, StructuredDatasetType, TypeStructure, UnionType + +T = typing.TypeVar("T") +DEFINITIONS = "definitions" +TITLE = "title" + + +class BatchSize: + """ + This is used to annotate a FlyteDirectory when we want to download/upload the contents of the directory in batches. For example, + + @task + def t1(directory: Annotated[FlyteDirectory, BatchSize(10)]) -> Annotated[FlyteDirectory, BatchSize(100)]: + ... + return FlyteDirectory(...) + + In the above example flytekit will download all files from the input `directory` in chunks of 10, i.e. first it + downloads 10 files, loads them to memory, then writes those 10 to local disk, then it loads the next 10, so on + and so forth. Similarly, for outputs, in this case flytekit is going to upload the resulting directory in chunks of + 100. + """ + + def __init__(self, val: int): + self._val = val + + @property + def val(self) -> int: + return self._val + + +def get_batch_size(t: Type) -> Optional[int]: + if is_annotated(t): + for annotation in get_args(t)[1:]: + if isinstance(annotation, BatchSize): + return annotation.val + return None + + +def modify_literal_uris(lit: Literal): + """ + Modifies the literal object recursively to replace the URIs with the native paths in case they are of + type "flyte://" + """ + from flytekit.remote.remote_fs import FlytePathResolver + + if lit.collection: + for l in lit.collection.literals: + modify_literal_uris(l) + elif lit.map: + for k, v in lit.map.literals.items(): + modify_literal_uris(v) + elif lit.scalar: + if lit.scalar.blob and lit.scalar.blob.uri and lit.scalar.blob.uri.startswith(FlytePathResolver.protocol): + lit.scalar.blob._uri = FlytePathResolver.resolve_remote_path(lit.scalar.blob.uri) + elif lit.scalar.union: + modify_literal_uris(lit.scalar.union.value) + elif ( + lit.scalar.structured_dataset + and lit.scalar.structured_dataset.uri + and lit.scalar.structured_dataset.uri.startswith(FlytePathResolver.protocol) + ): + lit.scalar.structured_dataset._uri = FlytePathResolver.resolve_remote_path( + lit.scalar.structured_dataset.uri + ) + + +class TypeTransformerFailedError(TypeError, AssertionError, ValueError): + ... + + +class TypeTransformer(typing.Generic[T]): + """ + Base transformer type that should be implemented for every python native type that can be handled by flytekit + """ + + def __init__(self, name: str, t: Type[T], enable_type_assertions: bool = True): + self._t = t + self._name = name + self._type_assertions_enabled = enable_type_assertions + + @property + def name(self): + return self._name + + @property + def python_type(self) -> Type[T]: + """ + This returns the python type + """ + return self._t + + @property + def type_assertions_enabled(self) -> bool: + """ + Indicates if the transformer wants type assertions to be enabled at the core type engine layer + """ + return self._type_assertions_enabled + + def assert_type(self, t: Type[T], v: T): + if not hasattr(t, "__origin__") and not isinstance(v, 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: + """ + Converts the python type to a Flyte LiteralType + """ + raise NotImplementedError("Conversion to LiteralType should be implemented") + + def guess_python_type(self, literal_type: LiteralType) -> Type[T]: + """ + Converts the Flyte LiteralType to a python object type. + """ + raise ValueError("By default, transformers do not translate from Flyte types back to Python types") + + @abstractmethod + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: + """ + Converts a given python_val to a Flyte Literal, assuming the given python_val matches the declared python_type. + Implementers should refrain from using type(python_val) instead rely on the passed in python_type. If these + do not match (or are not allowed) the Transformer implementer should raise an AssertionError, clearly stating + what was the mismatch + :param ctx: A FlyteContext, useful in accessing the filesystem and other attributes + :param python_val: The actual value to be transformed + :param python_type: The assumed type of the value (this matches the declared type on the function) + :param expected: Expected Literal Type + """ + raise NotImplementedError(f"Conversion to Literal for python type {python_type} not implemented") + + @abstractmethod + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> Optional[T]: + """ + Converts the given Literal to a Python Type. If the conversion cannot be done an AssertionError should be raised + :param ctx: FlyteContext + :param lv: The received literal Value + :param expected_python_type: Expected native python type that should be returned + """ + raise NotImplementedError( + f"Conversion to python value expected type {expected_python_type} from literal not implemented" + ) + + 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 + """ + return str(python_val) + + def __repr__(self): + return f"{self._name} Transforms ({self._t}) to Flyte native" + + def __str__(self): + return str(self.__repr__()) + + +class SimpleTransformer(TypeTransformer[T]): + """ + A Simple implementation of a type transformer that uses simple lambdas to transform and reduces boilerplate + """ + + def __init__( + self, + name: str, + t: Type[T], + lt: LiteralType, + to_literal_transformer: typing.Callable[[T], Literal], + from_literal_transformer: typing.Callable[[Literal], T], + ): + super().__init__(name, t) + self._type = t + self._lt = lt + self._to_literal_transformer = to_literal_transformer + self._from_literal_transformer = from_literal_transformer + + def get_literal_type(self, t: Optional[Type[T]] = None) -> LiteralType: + return LiteralType.from_flyte_idl(self._lt.to_flyte_idl()) + + 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 '{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: + expected_python_type = get_underlying_type(expected_python_type) + + if expected_python_type != self._type: + raise TypeTransformerFailedError( + f"Cannot convert to type {expected_python_type}, only {self._type} is supported" + ) + + try: # todo(maximsmol): this is quite ugly and each transformer should really check their Literal + res = self._from_literal_transformer(lv) + if type(res) != self._type: + raise TypeTransformerFailedError(f"Cannot convert literal {lv} to {self._type}") + return res + except AttributeError: + # Assume that this is because a property on `lv` was None + 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: + return self.python_type + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +class RestrictedTypeError(Exception): + pass + + +class RestrictedTypeTransformer(TypeTransformer[T], ABC): + """ + Types registered with the RestrictedTypeTransformer are not allowed to be converted to and from literals. In other words, + Restricted types are not allowed to be used as inputs or outputs of tasks and workflows. + """ + + def __init__(self, name: str, t: Type[T]): + super().__init__(name, t) + + def get_literal_type(self, t: Optional[Type[T]] = None) -> LiteralType: + raise RestrictedTypeError(f"Transformer for type {self.python_type} is restricted currently") + + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: + raise RestrictedTypeError(f"Transformer for type {self.python_type} is restricted currently") + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + raise RestrictedTypeError(f"Transformer for type {self.python_type} is restricted currently") + + +class DataclassTransformer(TypeTransformer[object]): + """ + The Dataclass Transformer provides a type transformer for dataclasses_json dataclasses. + + The Dataclass is converted to and from json and is transported between tasks using the proto.Structpb representation + Also the type declaration will try to extract the JSON Schema for the object if possible and pass it with the + definition. + + For Json Schema, we use https://github.com/fuhrysteve/marshmallow-jsonschema library. + + Example + + .. code-block:: python + + @dataclass + class Test(DataClassJsonMixin): + a: int + b: str + + from marshmallow_jsonschema import JSONSchema + t = Test(a=10,b="e") + JSONSchema().dump(t.schema()) + + Output will look like + + .. code-block:: json + + {'$schema': 'http://json-schema.org/draft-07/schema#', + 'definitions': {'TestSchema': {'properties': {'a': {'title': 'a', + 'type': 'number', + 'format': 'integer'}, + 'b': {'title': 'b', 'type': 'string'}}, + 'type': 'object', + 'additionalProperties': False}}, + '$ref': '#/definitions/TestSchema'} + + .. note:: + + The schema support is experimental and is useful for auto-completing in the UI/CLI + + """ + + def __init__(self): + super().__init__("Object-Dataclass-Transformer", object) + self._serializable_classes = [DataClassJSONMixin, DataClassJsonMixin] + try: + from mashumaro.mixins.orjson import DataClassORJSONMixin + + self._serializable_classes.append(DataClassORJSONMixin) + except ModuleNotFoundError: + pass + + def assert_type(self, expected_type: Type[DataClassJsonMixin], v: T): + # Skip iterating all attributes in the dataclass if the type of v already matches the expected_type + if type(v) == expected_type: + return + + # @dataclass + # class Foo(DataClassJsonMixin): + # a: int = 0 + # + # @task + # def t1(a: Foo): + # ... + # + # In above example, the type of v may not equal to the expected_type in some cases + # For example, + # 1. The input of t1 is another dataclass (bar), then we should raise an error + # 2. when using flyte remote to execute the above task, the expected_type is guess_python_type (FooSchema) by default. + # However, FooSchema is created by flytekit and it's not equal to the user-defined dataclass (Foo). + # Therefore, we should iterate all attributes in the dataclass and check the type of value in dataclass matches the expected_type. + + expected_fields_dict = {} + for f in dataclasses.fields(expected_type): + expected_fields_dict[f.name] = f.type + + if isinstance(v, dict): + original_dict = v + + # Find the Optional keys in expected_fields_dict + optional_keys = {k for k, t in expected_fields_dict.items() if UnionTransformer.is_optional_type(t)} + + # Remove the Optional keys from the keys of original_dict + original_key = set(original_dict.keys()) - optional_keys + expected_key = set(expected_fields_dict.keys()) - optional_keys + + # Check if original_key is missing any keys from expected_key + missing_keys = expected_key - original_key + if missing_keys: + raise TypeTransformerFailedError( + f"The original fields are missing the following keys from the dataclass fields: {list(missing_keys)}" + ) + + # Check if original_key has any extra keys that are not in expected_key + extra_keys = original_key - expected_key + if extra_keys: + raise TypeTransformerFailedError( + f"The original fields have the following extra keys that are not in dataclass fields: {list(extra_keys)}" + ) + + for k, v in original_dict.items(): + if k in expected_fields_dict: + if isinstance(v, dict): + self.assert_type(expected_fields_dict[k], v) + else: + expected_type = expected_fields_dict[k] + original_type = type(v) + if UnionTransformer.is_optional_type(expected_type): + expected_type = UnionTransformer.get_sub_type_in_optional(expected_type) + if original_type != expected_type: + raise TypeTransformerFailedError( + f"Type of Val '{original_type}' is not an instance of {expected_type}" + ) + + else: + for f in dataclasses.fields(type(v)): # type: ignore + original_type = f.type + expected_type = expected_fields_dict[f.name] + + if UnionTransformer.is_optional_type(original_type): + original_type = UnionTransformer.get_sub_type_in_optional(original_type) + if UnionTransformer.is_optional_type(expected_type): + expected_type = UnionTransformer.get_sub_type_in_optional(expected_type) + + val = v.__getattribute__(f.name) + if dataclasses.is_dataclass(val): + self.assert_type(expected_type, val) + elif original_type != expected_type: + raise TypeTransformerFailedError( + f"Type of Val '{original_type}' is not an instance of {expected_type}" + ) + + def get_literal_type(self, t: Type[T]) -> LiteralType: + """ + Extracts the Literal type definition for a Dataclass and returns a type Struct. + If possible also extracts the JSONSchema for the dataclass. + """ + if is_annotated(t): + raise ValueError( + "Flytekit does not currently have support for FlyteAnnotations applied to Dataclass." + f"Type {t} cannot be parsed." + ) + + if not self.is_serializable_class(t): + raise AssertionError( + f"Dataclass {t} should be decorated with @dataclass_json or mixin with DataClassJSONMixin to be " + f"serialized correctly" + ) + schema = None + try: + if issubclass(t, DataClassJsonMixin): + s = cast(DataClassJsonMixin, self._get_origin_type_in_annotation(t)).schema() + for _, v in s.fields.items(): + # marshmallow-jsonschema only supports enums loaded by name. + # https://github.com/fuhrysteve/marshmallow-jsonschema/blob/81eada1a0c42ff67de216923968af0a6b54e5dcb/marshmallow_jsonschema/base.py#L228 + if isinstance(v, EnumField): + v.load_by = LoadDumpOptions.name + # check if DataClass mixin + from marshmallow_jsonschema import JSONSchema + + schema = JSONSchema().dump(s) + else: # DataClassJSONMixin + from mashumaro.jsonschema import build_json_schema + + schema = build_json_schema(cast(DataClassJSONMixin, self._get_origin_type_in_annotation(t))).to_dict() + except Exception as e: + # https://github.com/lovasoa/marshmallow_dataclass/issues/13 + logger.warning( + f"Failed to extract schema for object {t}, (will run schemaless) error: {e}" + f"If you have postponed annotations turned on (PEP 563) turn it off please. Postponed" + f"evaluation doesn't work with json dataclasses" + ) + + # Recursively construct the dataclass_type which contains the literal type of each field + literal_type = {} + + # Get the type of each field from dataclass + for field in t.__dataclass_fields__.values(): # type: ignore + try: + literal_type[field.name] = TypeEngine.to_literal_type(field.type) + except Exception as e: + logger.warning( + "Field {} of type {} cannot be converted to a literal type. Error: {}".format( + field.name, field.type, e + ) + ) + + ts = TypeStructure(tag="", dataclass_type=literal_type) + + return _type_models.LiteralType(simple=_type_models.SimpleType.STRUCT, metadata=schema, structure=ts) + + def is_serializable_class(self, class_: Type[T]) -> bool: + return any(issubclass(class_, serializable_class) for serializable_class in self._serializable_classes) + + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: + if isinstance(python_val, dict): + json_str = json.dumps(python_val) + return Literal(scalar=Scalar(generic=_json_format.Parse(json_str, _struct.Struct()))) + + if not dataclasses.is_dataclass(python_val): + raise TypeTransformerFailedError( + f"{type(python_val)} is not of type @dataclass, only Dataclasses are supported for " + f"user defined datatypes in Flytekit" + ) + if not self.is_serializable_class(type(python_val)): + raise TypeTransformerFailedError( + f"Dataclass {python_type} should be decorated with @dataclass_json or inherit DataClassJSONMixin to be " + f"serialized correctly" + ) + self._serialize_flyte_type(python_val, python_type) + + json_str = python_val.to_json() # type: ignore + + return Literal(scalar=Scalar(generic=_json_format.Parse(json_str, _struct.Struct()))) # type: ignore + + def _get_origin_type_in_annotation(self, python_type: Type[T]) -> Type[T]: + # dataclass will try to hash python type when calling dataclass.schema(), but some types in the annotation is + # not hashable, such as Annotated[StructuredDataset, kwtypes(...)]. Therefore, we should just extract the origin + # type from annotated. + if get_origin(python_type) is list: + return typing.List[self._get_origin_type_in_annotation(get_args(python_type)[0])] # type: ignore + elif get_origin(python_type) is dict: + return typing.Dict[ # type: ignore + self._get_origin_type_in_annotation(get_args(python_type)[0]), + self._get_origin_type_in_annotation(get_args(python_type)[1]), + ] + elif is_annotated(python_type): + return get_args(python_type)[0] + elif dataclasses.is_dataclass(python_type): + for field in dataclasses.fields(copy.deepcopy(python_type)): + field.type = self._get_origin_type_in_annotation(field.type) + return python_type + + 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.types.structured import StructuredDataset + + if python_type == StructuredDataset and type(python_val) == dict: + return StructuredDataset(**python_val) + elif get_origin(python_type) is list: + return [self._fix_structured_dataset_type(get_args(python_type)[0], v) for v in python_val] # type: ignore + elif get_origin(python_type) is dict: + return { # type: ignore + self._fix_structured_dataset_type(get_args(python_type)[0], k): self._fix_structured_dataset_type( + get_args(python_type)[1], v + ) + for k, v in python_val.items() + } + elif dataclasses.is_dataclass(python_type): + for field in dataclasses.fields(python_type): + val = python_val.__getattribute__(field.name) + python_val.__setattr__(field.name, self._fix_structured_dataset_type(field.type, val)) + return python_val + + def _serialize_flyte_type(self, python_val: T, python_type: Type[T]) -> typing.Any: + """ + If any field inside the dataclass is flyte type, we should use flyte type transformer for that field. + """ + from flytekit.types.directory.types import FlyteDirectory + from flytekit.types.file import FlyteFile + from flytekit.types.schema.types import FlyteSchema + from flytekit.types.structured.structured_dataset import StructuredDataset + + # Handle Optional + if get_origin(python_type) is typing.Union and type(None) in get_args(python_type): + if python_val is None: + return None + return self._serialize_flyte_type(python_val, get_args(python_type)[0]) + + if hasattr(python_type, "__origin__") and get_origin(python_type) is list: + return [self._serialize_flyte_type(v, get_args(python_type)[0]) for v in cast(list, python_val)] + + if hasattr(python_type, "__origin__") and get_origin(python_type) is dict: + return { + k: self._serialize_flyte_type(v, get_args(python_type)[1]) for k, v in cast(dict, python_val).items() + } + + if not dataclasses.is_dataclass(python_type): + return python_val + + if inspect.isclass(python_type) and ( + issubclass(python_type, FlyteSchema) + or issubclass(python_type, FlyteFile) + or issubclass(python_type, FlyteDirectory) + or issubclass(python_type, StructuredDataset) + ): + lv = TypeEngine.to_literal(FlyteContext.current_context(), python_val, python_type, None) + # dataclasses_json package will extract the "path" from FlyteFile, FlyteDirectory, and write it to a + # JSON which will be stored in IDL. The path here should always be a remote path, but sometimes the + # path in FlyteFile and FlyteDirectory could be a local path. Therefore, reset the python value here, + # so that dataclasses_json can always get a remote path. + # In other words, the file transformer has special code that handles the fact that if remote_source is + # set, then the real uri in the literal should be the remote source, not the path (which may be an + # auto-generated random local path). To be sure we're writing the right path to the json, use the uri + # as determined by the transformer. + if issubclass(python_type, FlyteFile) or issubclass(python_type, FlyteDirectory): + return python_type(path=lv.scalar.blob.uri) + elif issubclass(python_type, StructuredDataset): + sd = python_type(uri=lv.scalar.structured_dataset.uri) + sd.file_format = lv.scalar.structured_dataset.metadata.structured_dataset_type.format + return sd + else: + return python_val + else: + for v in dataclasses.fields(python_type): + val = python_val.__getattribute__(v.name) + field_type = v.type + python_val.__setattr__(v.name, self._serialize_flyte_type(val, field_type)) + return python_val + + def _deserialize_flyte_type(self, python_val: T, expected_python_type: Type) -> Optional[T]: + from flytekit.types.directory.types import FlyteDirectory, FlyteDirToMultipartBlobTransformer + from flytekit.types.file.file import FlyteFile, FlyteFilePathTransformer + from flytekit.types.schema.types import FlyteSchema, FlyteSchemaTransformer + from flytekit.types.structured.structured_dataset import StructuredDataset, StructuredDatasetTransformerEngine + + # Handle Optional + if get_origin(expected_python_type) is typing.Union and type(None) in get_args(expected_python_type): + if python_val is None: + return None + return self._deserialize_flyte_type(python_val, get_args(expected_python_type)[0]) + + if hasattr(expected_python_type, "__origin__") and expected_python_type.__origin__ is list: + return [self._deserialize_flyte_type(v, expected_python_type.__args__[0]) for v in python_val] # type: ignore + + if hasattr(expected_python_type, "__origin__") and expected_python_type.__origin__ is dict: + return {k: self._deserialize_flyte_type(v, expected_python_type.__args__[1]) for k, v in python_val.items()} # type: ignore + + if not dataclasses.is_dataclass(expected_python_type): + return python_val + + if issubclass(expected_python_type, FlyteSchema): + t = FlyteSchemaTransformer() + return t.to_python_value( + FlyteContext.current_context(), + Literal( + scalar=Scalar( + schema=Schema( + cast(FlyteSchema, python_val).remote_path, t._get_schema_type(expected_python_type) + ) + ) + ), + expected_python_type, + ) + elif issubclass(expected_python_type, FlyteFile): + return FlyteFilePathTransformer().to_python_value( + FlyteContext.current_context(), + Literal( + scalar=Scalar( + blob=Blob( + metadata=BlobMetadata( + type=_core_types.BlobType( + format="", dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ), + uri=cast(FlyteFile, python_val).path, + ) + ) + ), + expected_python_type, + ) + elif issubclass(expected_python_type, FlyteDirectory): + return FlyteDirToMultipartBlobTransformer().to_python_value( + FlyteContext.current_context(), + Literal( + scalar=Scalar( + blob=Blob( + metadata=BlobMetadata( + type=_core_types.BlobType( + format="", dimensionality=_core_types.BlobType.BlobDimensionality.MULTIPART + ) + ), + uri=cast(FlyteDirectory, python_val).path, + ) + ) + ), + expected_python_type, + ) + elif issubclass(expected_python_type, StructuredDataset): + return StructuredDatasetTransformerEngine().to_python_value( + FlyteContext.current_context(), + Literal( + scalar=Scalar( + structured_dataset=StructuredDataset( + metadata=StructuredDatasetMetadata( + structured_dataset_type=StructuredDatasetType( + format=cast(StructuredDataset, python_val).file_format + ) + ), + uri=cast(StructuredDataset, python_val).uri, + ) + ) + ), + expected_python_type, + ) + else: + for f in dataclasses.fields(expected_python_type): + value = python_val.__getattribute__(f.name) + if hasattr(f.type, "__origin__") and f.type.__origin__ is list: + value = [self._deserialize_flyte_type(v, f.type.__args__[0]) for v in value] + elif hasattr(f.type, "__origin__") and f.type.__origin__ is dict: + value = {k: self._deserialize_flyte_type(v, f.type.__args__[1]) for k, v in value.items()} + else: + value = self._deserialize_flyte_type(value, f.type) + python_val.__setattr__(f.name, value) + return python_val + + def _fix_val_int(self, t: typing.Type, val: typing.Any) -> typing.Any: + if val is None: + return val + + if get_origin(t) is typing.Union and type(None) in get_args(t): + # Handle optional type. e.g. Optional[int], Optional[dataclass] + # Marshmallow doesn't support union type, so the type here is always an optional type. + # https://github.com/marshmallow-code/marshmallow/issues/1191#issuecomment-480831796 + # Note: Union[None, int] is also an optional type, but Marshmallow does not support it. + t = get_args(t)[0] + + if t == int: + return int(val) + + if isinstance(val, list): + # Handle nested List. e.g. [[1, 2], [3, 4]] + return list(map(lambda x: self._fix_val_int(ListTransformer.get_sub_type(t), x), val)) + + if isinstance(val, dict): + ktype, vtype = DictTransformer.get_dict_types(t) + # Handle nested Dict. e.g. {1: {2: 3}, 4: {5: 6}}) + return { + self._fix_val_int(cast(type, ktype), k): self._fix_val_int(cast(type, vtype), v) for k, v in val.items() + } + + if dataclasses.is_dataclass(t): + return self._fix_dataclass_int(t, val) # type: ignore + + return val + + def _fix_dataclass_int(self, dc_type: Type[DataClassJsonMixin], dc: DataClassJsonMixin) -> DataClassJsonMixin: + """ + This is a performance penalty to convert to the right types, but this is expected by the user and hence + needs to be done + """ + # NOTE: Protobuf Struct does not support explicit int types, int types are upconverted to a double value + # https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#google.protobuf.Value + # Thus we will have to walk the given dataclass and typecast values to int, where expected. + for f in dataclasses.fields(dc_type): + val = dc.__getattribute__(f.name) + dc.__setattr__(f.name, self._fix_val_int(f.type, val)) + return dc + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + if not dataclasses.is_dataclass(expected_python_type): + raise TypeTransformerFailedError( + f"{expected_python_type} is not of type @dataclass, only Dataclasses are supported for " + "user defined datatypes in Flytekit" + ) + if not self.is_serializable_class(expected_python_type): + raise TypeTransformerFailedError( + f"Dataclass {expected_python_type} should be decorated with @dataclass_json or mixin with DataClassJSONMixin to be " + f"serialized correctly" + ) + json_str = _json_format.MessageToJson(lv.scalar.generic) + dc = expected_python_type.from_json(json_str) # type: ignore + + dc = self._fix_structured_dataset_type(expected_python_type, dc) + return self._fix_dataclass_int(expected_python_type, self._deserialize_flyte_type(dc, expected_python_type)) + + # This ensures that calls with the same literal type returns the same dataclass. For example, `pyflyte run`` + # command needs to call guess_python_type to get the TypeEngine-derived dataclass. Without caching here, separate + # calls to guess_python_type would result in a logically equivalent (but new) dataclass, which + # TypeEngine.assert_type would not be happy about. + @lru_cache(typed=True) + def guess_python_type(self, literal_type: LiteralType) -> Type[T]: # type: ignore + if literal_type.simple == SimpleType.STRUCT: + if literal_type.metadata is not None: + if DEFINITIONS in literal_type.metadata: + schema_name = literal_type.metadata["$ref"].split("/")[-1] + return convert_marshmallow_json_schema_to_python_class( + literal_type.metadata[DEFINITIONS], schema_name + ) + elif TITLE in literal_type.metadata: + schema_name = literal_type.metadata[TITLE] + return convert_mashumaro_json_schema_to_python_class(literal_type.metadata, schema_name) + raise ValueError(f"Dataclass transformer cannot reverse {literal_type}") + + +class ProtobufTransformer(TypeTransformer[Message]): + PB_FIELD_KEY = "pb_type" + + def __init__(self): + super().__init__("Protobuf-Transformer", Message) + + @staticmethod + def tag(expected_python_type: Type[T]) -> str: + return f"{expected_python_type.__module__}.{expected_python_type.__name__}" + + def get_literal_type(self, t: Type[T]) -> LiteralType: + return LiteralType(simple=SimpleType.STRUCT, metadata={ProtobufTransformer.PB_FIELD_KEY: self.tag(t)}) + + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: + struct = Struct() + try: + struct.update(_MessageToDict(cast(Message, python_val))) + except Exception: + raise TypeTransformerFailedError("Failed to convert to generic protobuf struct") + return Literal(scalar=Scalar(generic=struct)) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + if not (lv and lv.scalar and lv.scalar.generic is not None): + raise TypeTransformerFailedError("Can only convert a generic literal to a Protobuf") + + pb_obj = expected_python_type() + dictionary = _MessageToDict(lv.scalar.generic) + pb_obj = _ParseDict(dictionary, pb_obj) # type: ignore + return pb_obj + + def guess_python_type(self, literal_type: LiteralType) -> Type[T]: + if ( + literal_type.simple == SimpleType.STRUCT + and literal_type.metadata + and literal_type.metadata.get(self.PB_FIELD_KEY, "") + ): + tag = literal_type.metadata[self.PB_FIELD_KEY] + return load_type_from_tag(tag) + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +class EnumTransformer(TypeTransformer[enum.Enum]): + """ + Enables converting a python type enum.Enum to LiteralType.EnumType + """ + + def __init__(self): + super().__init__(name="DefaultEnumTransformer", t=enum.Enum) + + def get_literal_type(self, t: Type[T]) -> LiteralType: + if is_annotated(t): + raise ValueError( + f"Flytekit does not currently have support \ + for FlyteAnnotations applied to enums. {t} cannot be \ + parsed." + ) + + values = [v.value for v in t] # type: ignore + if not isinstance(values[0], str): + raise TypeTransformerFailedError("Only EnumTypes with value of string are supported") + return LiteralType(enum_type=_core_types.EnumType(values=values)) + + def to_literal( + self, ctx: FlyteContext, python_val: enum.Enum, python_type: Type[T], expected: LiteralType + ) -> Literal: + if type(python_val).__class__ != enum.EnumMeta: + raise TypeTransformerFailedError("Expected an enum") + if type(python_val.value) != str: + raise TypeTransformerFailedError("Only string-valued enums are supportedd") + + return Literal(scalar=Scalar(primitive=Primitive(string_value=python_val.value))) # type: ignore + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + return expected_python_type(lv.scalar.primitive.string_value) # type: ignore + + def guess_python_type(self, literal_type: LiteralType) -> Type[enum.Enum]: + if literal_type.enum_type: + return enum.Enum("DynamicEnum", {f"{i}": i for i in literal_type.enum_type.values}) # type: ignore + raise ValueError(f"Enum transformer cannot reverse {literal_type}") + + +def generate_attribute_list_from_dataclass_json_mixin(schema: dict, schema_name: typing.Any): + attribute_list = [] + for property_key, property_val in schema["properties"].items(): + if property_val.get("anyOf"): + property_type = property_val["anyOf"][0]["type"] + elif property_val.get("enum"): + property_type = "enum" + else: + property_type = property_val["type"] + # Handle list + if property_type == "array": + attribute_list.append((property_key, typing.List[_get_element_type(property_val["items"])])) # type: ignore + # Handle dataclass and dict + elif property_type == "object": + if property_val.get("anyOf"): + sub_schemea = property_val["anyOf"][0] + sub_schemea_name = sub_schemea["title"] + attribute_list.append( + (property_key, convert_mashumaro_json_schema_to_python_class(sub_schemea, sub_schemea_name)) + ) + elif property_val.get("additionalProperties"): + attribute_list.append( + (property_key, typing.Dict[str, _get_element_type(property_val["additionalProperties"])]) # type: ignore + ) + else: + sub_schemea_name = property_val["title"] + attribute_list.append( + (property_key, convert_mashumaro_json_schema_to_python_class(property_val, sub_schemea_name)) + ) + elif property_type == "enum": + attribute_list.append([property_key, str]) # type: ignore + # Handle int, float, bool or str + else: + attribute_list.append([property_key, _get_element_type(property_val)]) # type: ignore + return attribute_list + + +class TypeEngine(typing.Generic[T]): + """ + Core Extensible TypeEngine of Flytekit. This should be used to extend the capabilities of FlyteKits type system. + Users can implement their own TypeTransformers and register them with the TypeEngine. This will allow special handling + of user objects + """ + + _REGISTRY: typing.Dict[type, TypeTransformer[T]] = {} + _RESTRICTED_TYPES: typing.List[type] = [] + _DATACLASS_TRANSFORMER: TypeTransformer = DataclassTransformer() # type: ignore + _ENUM_TRANSFORMER: TypeTransformer = EnumTransformer() # type: ignore + has_lazy_import = False + + @classmethod + def register( + cls, + transformer: TypeTransformer, + additional_types: Optional[typing.List[Type]] = None, + ): + """ + This should be used for all types that respond with the right type annotation when you use type(...) function + """ + types = [transformer.python_type, *(additional_types or [])] + for t in types: + if t in cls._REGISTRY: + existing = cls._REGISTRY[t] + raise ValueError( + f"Transformer {existing.name} for type {t} is already registered." + f" Cannot override with {transformer.name}" + ) + cls._REGISTRY[t] = transformer + + @classmethod + def register_restricted_type( + cls, + name: str, + type: Type[T], + ): + cls._RESTRICTED_TYPES.append(type) + cls.register(RestrictedTypeTransformer(name, type)) # type: ignore + + @classmethod + def register_additional_type(cls, transformer: TypeTransformer, additional_type: Type, override=False): + if additional_type not in cls._REGISTRY or override: + cls._REGISTRY[additional_type] = transformer + + @classmethod + def get_transformer(cls, python_type: Type) -> TypeTransformer[T]: + """ + The TypeEngine hierarchy for flyteKit. This method looksup and selects the type transformer. The algorithm is + as follows + + d = dictionary of registered transformers, where is a python `type` + v = lookup type + Step 1: + If the type is annotated with a TypeTransformer instance, use that. + + Step 2: + 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. + + Special case: + If v inherits from Enum, use the Enum transformer even if Enum is not the first base class. + + TODO lets make this deterministic by using an ordered dict + + Step 5: + if v is of type data class, use the dataclass transformer + """ + cls.lazy_import_transformers() + # Step 1 + if is_annotated(python_type): + args = get_args(python_type) + for annotation in args: + if isinstance(annotation, TypeTransformer): + return annotation + + python_type = args[0] + + # Step 2 + # this makes sure that if it's a list/dict of annotated types, we hit the unwrapping code in step 2 + # see test_list_of_annotated in test_structured_dataset.py + if ( + (not hasattr(python_type, "__origin__")) + or ( + hasattr(python_type, "__origin__") + and (python_type.__origin__ is not list and python_type.__origin__ is not dict) + ) + ) and python_type in cls._REGISTRY: + return cls._REGISTRY[python_type] + + # Step 3 + if hasattr(python_type, "__origin__"): + # Handling of annotated generics, eg: + # Annotated[typing.List[int], 'foo'] + if is_annotated(python_type): + return cls.get_transformer(get_args(python_type)[0]) + + if python_type.__origin__ in cls._REGISTRY: + return cls._REGISTRY[python_type.__origin__] + + raise ValueError(f"Generic Type {python_type.__origin__} not supported currently in Flytekit.") + + # Step 4 + # To facilitate cases where users may specify one transformer for multiple types that all inherit from one + # parent. + if inspect.isclass(python_type) and issubclass(python_type, enum.Enum): + # Special case: prevent that for a type `FooEnum(str, Enum)`, the str transformer is used. + return cls._ENUM_TRANSFORMER + + for base_type in cls._REGISTRY.keys(): + if base_type is None: + continue # None is actually one of the keys, but isinstance/issubclass doesn't work on it + try: + if isinstance(python_type, base_type) or ( + inspect.isclass(python_type) and issubclass(python_type, base_type) + ): + return cls._REGISTRY[base_type] + except TypeError: + # As of python 3.9, calls to isinstance raise a TypeError if the base type is not a valid type, which + # is the case for one of the restricted types, namely NamedTuple. + logger.debug(f"Invalid base type {base_type} in call to isinstance", exc_info=True) + + # Step 5 + if dataclasses.is_dataclass(python_type): + return cls._DATACLASS_TRANSFORMER + + # Step 6 + display_pickle_warning(str(python_type)) + from flytekit.types.pickle.pickle import FlytePickleTransformer + + return FlytePickleTransformer() + + @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.schema.types_pandas import PandasSchemaReader, PandasSchemaWriter # 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 + if is_imported("PIL"): + from flytekit.types.file import image # noqa: F401 + + @classmethod + def to_literal_type(cls, python_type: Type) -> LiteralType: + """ + Converts a python type into a flyte specific ``LiteralType`` + """ + transformer = cls.get_transformer(python_type) + res = transformer.get_literal_type(python_type) + data = None + if is_annotated(python_type): + for x in get_args(python_type)[1:]: + if not isinstance(x, FlyteAnnotation): + continue + if data is not None: + raise ValueError( + f"More than one FlyteAnnotation used within {python_type} typehint. Flytekit requires a max of one." + ) + data = x.data + if data is not None: + idl_type_annotation = TypeAnnotationModel(annotations=data) + res = LiteralType.from_flyte_idl(res.to_flyte_idl()) + res._annotation = idl_type_annotation + return res + + @classmethod + def to_literal(cls, ctx: FlyteContext, python_val: typing.Any, python_type: Type, expected: LiteralType) -> Literal: + """ + Converts a python value of a given type and expected ``LiteralType`` into a resolved ``Literal`` value. + """ + from flytekit.core.promise import Promise, VoidPromise + + if isinstance(python_val, Promise): + # In the example above, this handles the "in2=a" type of argument + return python_val.val + if isinstance(python_val, VoidPromise): + raise AssertionError( + f"Outputs of a non-output producing task {python_val.task_name} cannot be passed to another task." + ) + if isinstance(python_val, tuple): + raise AssertionError( + "Tuples are not a supported type for individual values in Flyte - got a tuple -" + f" {python_val}. If using named tuple in an inner task, please, de-reference the" + "actual attribute that you want to use. For example, in NamedTuple('OP', x=int) then" + "return v.x, instead of v, even if this has a single element" + ) + if python_val is None and expected and expected.union_type is None: + raise TypeTransformerFailedError(f"Python value cannot be None, expected {python_type}/{expected}") + transformer = cls.get_transformer(python_type) + if transformer.type_assertions_enabled: + transformer.assert_type(python_type, python_val) + + # In case the value is an annotated type we inspect the annotations and look for hash-related annotations. + hash = None + if is_annotated(python_type): + # We are now dealing with one of two cases: + # 1. The annotated type is a `HashMethod`, which indicates that we should produce the hash using + # the method indicated in the annotation. + # 2. The annotated type is being used for a different purpose other than calculating hash values, in which case + # we should just continue. + for annotation in get_args(python_type)[1:]: + if not isinstance(annotation, HashMethod): + continue + hash = annotation.calculate(python_val) + break + + lv = transformer.to_literal(ctx, python_val, python_type, expected) + modify_literal_uris(lv) + if hash is not None: + lv.hash = hash + return lv + + @classmethod + def to_python_value(cls, ctx: FlyteContext, lv: Literal, expected_python_type: Type) -> typing.Any: + """ + Converts a Literal value with an expected python type into a python value. + """ + transformer = cls.get_transformer(expected_python_type) + 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[typing.Any]) -> str: + transformer = cls.get_transformer(expected_python_type) + if is_annotated(expected_python_type): + expected_python_type, *annotate_args = get_args(expected_python_type) + from flytekit.deck.renderer import Renderable + + for arg in annotate_args: + if isinstance(arg, Renderable): + return arg.to_html(python_val) + return transformer.to_html(ctx, python_val, expected_python_type) + + @classmethod + def named_tuple_to_variable_map(cls, t: typing.NamedTuple) -> _interface_models.VariableMap: + """ + Converts a python-native ``NamedTuple`` to a flyte-specific VariableMap of named literals. + """ + variables = {} + for idx, (var_name, var_type) in enumerate(t.__annotations__.items()): + literal_type = cls.to_literal_type(var_type) + variables[var_name] = _interface_models.Variable(type=literal_type, description=f"{idx}") + 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]: + """ + Given a ``LiteralMap`` (usually an input into a task - intermediate), convert to kwargs for the task + """ + if len(lm.literals) > len(python_types): + raise ValueError( + f"Received more input values {len(lm.literals)}" f" than allowed by the input spec {len(python_types)}" + ) + 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( + cls, + ctx: FlyteContext, + d: typing.Dict[str, typing.Any], + type_hints: Optional[typing.Dict[str, type]] = None, + ) -> LiteralMap: + """ + Given a dictionary mapping string keys to python values and a dictionary containing guessed types for such string keys, + convert to a LiteralMap. + """ + type_hints = type_hints or {} + literal_map = {} + for k, v in d.items(): + # The guessed type takes precedence over the type returned by the python runtime. This is needed + # to account for the type erasure that happens in the case of built-in collection containers, such as + # `list` and `dict`. + python_type = type_hints.get(k, type(v)) + try: + literal_map[k] = TypeEngine.to_literal( + ctx=ctx, + python_val=v, + python_type=python_type, + expected=TypeEngine.to_literal_type(python_type), + ) + except TypeError: + raise user_exceptions.FlyteTypeException(type(v), python_type, received_value=v) + return LiteralMap(literal_map) + + @classmethod + def get_available_transformers(cls) -> typing.KeysView[Type]: + """ + Returns all python types for which transformers are available + """ + return cls._REGISTRY.keys() + + @classmethod + def guess_python_types( + cls, flyte_variable_dict: typing.Dict[str, _interface_models.Variable] + ) -> typing.Dict[str, type]: + """ + Transforms a dictionary of flyte-specific ``Variable`` objects to a dictionary of regular python values. + """ + python_types = {} + for k, v in flyte_variable_dict.items(): + python_types[k] = cls.guess_python_type(v.type) + return python_types + + @classmethod + def guess_python_type(cls, flyte_type: LiteralType) -> type: + """ + Transforms a flyte-specific ``LiteralType`` to a regular python value. + """ + for _, transformer in cls._REGISTRY.items(): + try: + return transformer.guess_python_type(flyte_type) + except ValueError: + logger.debug(f"Skipping transformer {transformer.name} for {flyte_type}") + + # Because the dataclass transformer is handled explicitly in the get_transformer code, we have to handle it + # separately here too. + try: + return cls._DATACLASS_TRANSFORMER.guess_python_type(literal_type=flyte_type) + except ValueError: + logger.debug(f"Skipping transformer {cls._DATACLASS_TRANSFORMER.name} for {flyte_type}") + raise ValueError(f"No transformers could reverse Flyte literal type {flyte_type}") + + +class ListTransformer(TypeTransformer[T]): + """ + Transformer that handles a univariate typing.List[T] + """ + + def __init__(self): + super().__init__("Typed List", list) + + @staticmethod + def get_sub_type(t: Type[T]) -> Type[T]: + """ + Return the generic Type T of the List + """ + if (sub_type := ListTransformer.get_sub_type_or_none(t)) is not None: + return sub_type + + raise ValueError("Only generic univariate typing.List[T] type is supported.") + + @staticmethod + def get_sub_type_or_none(t: Type[T]) -> Optional[Type[T]]: + """ + Return the generic Type T of the List, or None if the generic type cannot be inferred + """ + if hasattr(t, "__origin__"): + # Handle annotation on list generic, eg: + # Annotated[typing.List[int], 'foo'] + if is_annotated(t): + return ListTransformer.get_sub_type(get_args(t)[0]) + + if getattr(t, "__origin__") is list and hasattr(t, "__args__"): + return getattr(t, "__args__")[0] + + return None + + def get_literal_type(self, t: Type[T]) -> Optional[LiteralType]: + """ + Only univariate Lists are supported in Flyte + """ + try: + sub_type = TypeEngine.to_literal_type(self.get_sub_type(t)) + return _type_models.LiteralType(collection_type=sub_type) + 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 is_annotated(t): + 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") + + 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 is_annotated(python_type): + 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[typing.Any]: # type: ignore + try: + lits = lv.collection.literals + except AttributeError: + raise TypeTransformerFailedError( + ( + f"The expected python type is '{expected_python_type}' but the received Flyte literal value " + f"is not a collection (Flyte's representation of Python lists)." + ) + ) + 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) -> list: # type: ignore + if literal_type.collection_type: + ct: Type = TypeEngine.guess_python_type(literal_type.collection_type) + return typing.List[ct] # type: ignore + raise ValueError(f"List transformer cannot reverse {literal_type}") + + +@lru_cache +def display_pickle_warning(python_type: str): + # This is a warning that is only displayed once per python type + logger.warning( + f"Unsupported Type {python_type} found, Flyte will default to use PickleFile as the transport. " + f"Pickle can only be used to send objects between the exact same version of Python, " + f"and we strongly recommend to use python type that flyte support." + ) + + +def _add_tag_to_type(x: LiteralType, tag: str) -> LiteralType: + x._structure = TypeStructure(tag=tag) + return x + + +def _type_essence(x: LiteralType) -> LiteralType: + if x.metadata is not None or x.structure is not None or x.annotation is not None: + x = LiteralType.from_flyte_idl(x.to_flyte_idl()) + x._metadata = None + x._structure = None + x._annotation = None + + return x + + +def _are_types_castable(upstream: LiteralType, downstream: LiteralType) -> bool: + if upstream.collection_type is not None: + if downstream.collection_type is None: + return False + + return _are_types_castable(upstream.collection_type, downstream.collection_type) + + if upstream.map_value_type is not None: + if downstream.map_value_type is None: + return False + + return _are_types_castable(upstream.map_value_type, downstream.map_value_type) + + # TODO: Structured dataset type matching requires that downstream structured datasets + # are a strict sub-set of the upstream structured dataset. + if upstream.structured_dataset_type is not None: + if downstream.structured_dataset_type is None: + return False + + usdt = upstream.structured_dataset_type + dsdt = downstream.structured_dataset_type + + if usdt.format != dsdt.format: + return False + + if usdt.external_schema_type != dsdt.external_schema_type: + return False + + if usdt.external_schema_bytes != dsdt.external_schema_bytes: + return False + + ucols = usdt.columns + dcols = dsdt.columns + + if len(ucols) != len(dcols): + return False + + for u, d in zip(ucols, dcols): + if u.name != d.name: + return False + + if not _are_types_castable(u.literal_type, d.literal_type): + return False + + return True + + if upstream.union_type is not None: + # for each upstream variant, there must be a compatible type downstream + for v in upstream.union_type.variants: + if not _are_types_castable(v, downstream): + return False + return True + + if downstream.union_type is not None: + # there must be a compatible downstream type + for v in downstream.union_type.variants: + if _are_types_castable(upstream, v): + return True + + if upstream.enum_type is not None: + # enums are castable to string + if downstream.simple == SimpleType.STRING: + return True + + if _type_essence(upstream) == _type_essence(downstream): + return True + + return False + + +class UnionTransformer(TypeTransformer[T]): + """ + Transformer that handles a typing.Union[T1, T2, ...] + """ + + def __init__(self): + super().__init__("Typed Union", typing.Union) + + @staticmethod + def is_optional_type(t: Type[T]) -> bool: + return get_origin(t) is typing.Union and type(None) in get_args(t) + + @staticmethod + def get_sub_type_in_optional(t: Type[T]) -> Type[T]: + """ + Return the generic Type T of the Optional type + """ + return get_args(t)[0] + + def get_literal_type(self, t: Type[T]) -> Optional[LiteralType]: + t = get_underlying_type(t) + + try: + trans: typing.List[typing.Tuple[TypeTransformer, typing.Any]] = [ + (TypeEngine.get_transformer(x), x) for x in get_args(t) + ] + # must go through TypeEngine.to_literal_type instead of trans.get_literal_type + # to handle Annotated + variants = [_add_tag_to_type(TypeEngine.to_literal_type(x), t.name) for (t, x) in trans] + return _type_models.LiteralType(union_type=UnionType(variants)) + except Exception as e: + raise ValueError(f"Type of Generic Union type is not supported, {e}") + + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: + python_type = get_underlying_type(python_type) + + found_res = False + is_ambiguous = False + res = None + res_type = None + for i in range(len(get_args(python_type))): + try: + t = get_args(python_type)[i] + trans: TypeTransformer[T] = TypeEngine.get_transformer(t) + res = trans.to_literal(ctx, python_val, t, expected.union_type.variants[i]) + res_type = _add_tag_to_type(trans.get_literal_type(t), trans.name) + if found_res: + is_ambiguous = True + found_res = True + except Exception as e: + logger.debug(f"Failed to convert from {python_val} to {t}", e) + continue + + if is_ambiguous: + raise TypeError("Ambiguous choice of variant for union type") + + if found_res: + return Literal(scalar=Scalar(union=Union(value=res, stored_type=res_type))) + + raise TypeTransformerFailedError(f"Cannot convert from {python_val} to {python_type}") + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> Optional[typing.Any]: + expected_python_type = get_underlying_type(expected_python_type) + + union_tag = None + union_type = None + if lv.scalar is not None and lv.scalar.union is not None: + union_type = lv.scalar.union.stored_type + if union_type.structure is not None: + union_tag = union_type.structure.tag + + found_res = False + is_ambiguous = False + cur_transformer = "" + res = None + res_tag = None + for v in get_args(expected_python_type): + try: + trans: TypeTransformer[T] = TypeEngine.get_transformer(v) + if union_tag is not None: + if trans.name != union_tag: + continue + + expected_literal_type = TypeEngine.to_literal_type(v) + if not _are_types_castable(union_type, expected_literal_type): + continue + + assert lv.scalar is not None # type checker + assert lv.scalar.union is not None # type checker + + res = trans.to_python_value(ctx, lv.scalar.union.value, v) + if found_res: + is_ambiguous = True + cur_transformer = trans.name + break + else: + res = trans.to_python_value(ctx, lv, v) + if found_res: + is_ambiguous = True + cur_transformer = trans.name + break + res_tag = trans.name + found_res = True + except Exception as e: + logger.debug(f"Failed to convert from {lv} to {v}", e) + + if is_ambiguous: + raise TypeError( + "Ambiguous choice of variant for union type. " + + f"Both {res_tag} and {cur_transformer} transformers match" + ) + + if found_res: + return res + + raise TypeError(f"Cannot convert from {lv} to {expected_python_type} (using tag {union_tag})") + + def guess_python_type(self, literal_type: LiteralType) -> type: + if literal_type.union_type is not None: + return typing.Union[tuple(TypeEngine.guess_python_type(v) for v in literal_type.union_type.variants)] # type: ignore + + raise ValueError(f"Union transformer cannot reverse {literal_type}") + + +class DictTransformer(TypeTransformer[dict]): + """ + Transformer that transforms a univariate dictionary Dict[str, T] to a Literal Map or + transforms a untyped dictionary to a JSON (struct/Generic) + """ + + def __init__(self): + super().__init__("Typed Dict", dict) + + @staticmethod + def get_dict_types(t: Optional[Type[dict]]) -> typing.Tuple[Optional[type], Optional[type]]: + """ + Return the generic Type T of the Dict + """ + _origin = get_origin(t) + _args = get_args(t) + if _origin is not None: + if _origin is Annotated: + raise ValueError( + f"Flytekit does not currently have support \ + for FlyteAnnotations applied to dicts. {t} cannot be \ + parsed." + ) + if _origin is dict and _args is not None: + return _args # type: ignore + return None, None + + @staticmethod + def dict_to_generic_literal(v: dict) -> Literal: + """ + Creates a flyte-specific ``Literal`` value from a native python dictionary. + """ + return Literal(scalar=Scalar(generic=_json_format.Parse(_json.dumps(v), _struct.Struct()))) + + def get_literal_type(self, t: Type[dict]) -> LiteralType: + """ + Transforms a native python dictionary to a flyte-specific ``LiteralType`` + """ + tp = self.get_dict_types(t) + if tp: + if tp[0] == str: + try: + sub_type = TypeEngine.to_literal_type(cast(type, tp[1])) + return _type_models.LiteralType(map_value_type=sub_type) + except Exception as e: + raise ValueError(f"Type of Generic List type is not supported, {e}") + return _type_models.LiteralType(simple=_type_models.SimpleType.STRUCT) + + def to_literal( + self, ctx: FlyteContext, python_val: typing.Any, python_type: Type[dict], expected: LiteralType + ) -> Literal: + if type(python_val) != dict: + raise TypeTransformerFailedError("Expected a dict") + + if expected and expected.simple and expected.simple == SimpleType.STRUCT: + return self.dict_to_generic_literal(python_val) + + lit_map = {} + for k, v in python_val.items(): + if type(k) != str: + raise ValueError("Flyte MapType expects all keys to be strings") + # TODO: log a warning for Annotated objects that contain HashMethod + k_type, v_type = self.get_dict_types(python_type) + lit_map[k] = TypeEngine.to_literal(ctx, v, cast(type, v_type), expected.map_value_type) + return Literal(map=LiteralMap(literals=lit_map)) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[dict]) -> dict: + if lv and lv.map and lv.map.literals is not None: + tp = self.get_dict_types(expected_python_type) + if tp is None or tp[0] is None: + raise TypeError( + "TypeMismatch: Cannot convert to python dictionary from Flyte Literal Dictionary as the given " + "dictionary does not have sub-type hints or they do not match with the originating dictionary " + "source. Flytekit does not currently support implicit conversions" + ) + if tp[0] != str: + raise TypeError("TypeMismatch. Destination dictionary does not accept 'str' key") + py_map = {} + for k, v in lv.map.literals.items(): + py_map[k] = TypeEngine.to_python_value(ctx, v, cast(Type, tp[1])) + return py_map + + # for empty generic we have to explicitly test for lv.scalar.generic is not None as empty dict + # evaluates to false + if lv and lv.scalar and lv.scalar.generic is not None: + try: + return _json.loads(_json_format.MessageToJson(lv.scalar.generic)) + except TypeError: + raise TypeTransformerFailedError(f"Cannot convert from {lv} to {expected_python_type}") + raise TypeTransformerFailedError(f"Cannot convert from {lv} to {expected_python_type}") + + def guess_python_type(self, literal_type: LiteralType) -> Union[Type[dict], typing.Dict[Type, Type]]: + if literal_type.map_value_type: + mt = TypeEngine.guess_python_type(literal_type.map_value_type) + return typing.Dict[str, mt] # type: ignore + + if literal_type.simple == SimpleType.STRUCT: + if literal_type.metadata is None: + return dict # type: ignore + + raise ValueError(f"Dictionary transformer cannot reverse {literal_type}") + + +class TextIOTransformer(TypeTransformer[typing.TextIO]): + """ + Handler for TextIO + """ + + def __init__(self): + super().__init__(name="TextIO", t=typing.TextIO) + + def _blob_type(self) -> _core_types.BlobType: + return _core_types.BlobType( + format=mimetypes.types_map[".txt"], + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + + def get_literal_type(self, t: typing.TextIO) -> LiteralType: # type: ignore + return _type_models.LiteralType(blob=self._blob_type()) + + def to_literal( + self, ctx: FlyteContext, python_val: typing.TextIO, python_type: Type[typing.TextIO], expected: LiteralType + ) -> Literal: + raise NotImplementedError("Implement handle for TextIO") + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[typing.TextIO] + ) -> typing.TextIO: + # TODO rename to get_auto_local_path() + local_path = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(lv.scalar.blob.uri, local_path, is_multipart=False) + # TODO it is probably the responsibility of the framework to close() this + return open(local_path, "r") + + +class BinaryIOTransformer(TypeTransformer[typing.BinaryIO]): + """ + Handler for BinaryIO + """ + + def __init__(self): + super().__init__(name="BinaryIO", t=typing.BinaryIO) + + def _blob_type(self) -> _core_types.BlobType: + return _core_types.BlobType( + format=mimetypes.types_map[".bin"], + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + + def get_literal_type(self, t: Type[typing.BinaryIO]) -> LiteralType: + return _type_models.LiteralType( + blob=self._blob_type(), + ) + + def to_literal( + self, ctx: FlyteContext, python_val: typing.BinaryIO, python_type: Type[typing.BinaryIO], expected: LiteralType + ) -> Literal: + raise NotImplementedError("Implement handle for TextIO") + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[typing.BinaryIO] + ) -> typing.BinaryIO: + local_path = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(lv.scalar.blob.uri, local_path, is_multipart=False) + # TODO it is probability the responsibility of the framework to close this + return open(local_path, "rb") + + +def generate_attribute_list_from_dataclass_json(schema: dict, schema_name: typing.Any): + attribute_list = [] + for property_key, property_val in schema[schema_name]["properties"].items(): + property_type = property_val["type"] + # Handle list + if property_val["type"] == "array": + attribute_list.append((property_key, List[_get_element_type(property_val["items"])])) # type: ignore[misc,index] + # Handle dataclass and dict + elif property_type == "object": + if property_val.get("$ref"): + name = property_val["$ref"].split("/")[-1] + attribute_list.append((property_key, convert_marshmallow_json_schema_to_python_class(schema, name))) + elif property_val.get("additionalProperties"): + attribute_list.append( + (property_key, Dict[str, _get_element_type(property_val["additionalProperties"])]) # type: ignore[misc,index] + ) + else: + attribute_list.append((property_key, Dict[str, _get_element_type(property_val)])) # type: ignore[misc,index] + # Handle int, float, bool or str + else: + attribute_list.append([property_key, _get_element_type(property_val)]) # type: ignore + return attribute_list + + +def convert_marshmallow_json_schema_to_python_class( + schema: dict, schema_name: typing.Any +) -> Type[dataclasses.dataclass()]: # type: ignore + """ + Generate a model class based on the provided JSON Schema + :param schema: dict representing valid JSON schema + :param schema_name: dataclass name of return type + """ + + attribute_list = generate_attribute_list_from_dataclass_json(schema, schema_name) + return dataclass_json(dataclasses.make_dataclass(schema_name, attribute_list)) + + +def convert_mashumaro_json_schema_to_python_class( + schema: dict, schema_name: typing.Any +) -> Type[dataclasses.dataclass()]: # type: ignore + """ + Generate a model class based on the provided JSON Schema + :param schema: dict representing valid JSON schema + :param schema_name: dataclass name of return type + """ + + attribute_list = generate_attribute_list_from_dataclass_json_mixin(schema, schema_name) + return dataclass_json(dataclasses.make_dataclass(schema_name, attribute_list)) + + +def _get_element_type(element_property: typing.Dict[str, str]) -> Type: + element_type = ( + [e_property["type"] for e_property in element_property["anyOf"]] # type: ignore + if element_property.get("anyOf") + else element_property["type"] + ) + element_format = element_property["format"] if "format" in element_property else None + + if type(element_type) == list: + # Element type of Optional[int] is [integer, None] + return typing.Optional[_get_element_type({"type": element_type[0]})] # type: ignore + + if element_type == "string": + return str + elif element_type == "integer": + return int + elif element_type == "boolean": + return bool + elif element_type == "number": + if element_format == "integer": + return int + else: + return float + return str + + +def dataclass_from_dict(cls: type, src: typing.Dict[str, typing.Any]) -> typing.Any: + """ + Utility function to construct a dataclass object from dict + """ + field_types_lookup = {field.name: field.type for field in dataclasses.fields(cls)} + + constructor_inputs = {} + for field_name, value in src.items(): + if dataclasses.is_dataclass(field_types_lookup[field_name]): + constructor_inputs[field_name] = dataclass_from_dict(field_types_lookup[field_name], value) + else: + constructor_inputs[field_name] = value + + return cls(**constructor_inputs) + + +def _check_and_covert_float(lv: Literal) -> float: + if lv.scalar.primitive.float_value is not None: + return lv.scalar.primitive.float_value + elif lv.scalar.primitive.integer is not None: + return float(lv.scalar.primitive.integer) + raise TypeTransformerFailedError(f"Cannot convert literal {lv} to float") + + +def _check_and_convert_void(lv: Literal) -> None: + if lv.scalar.none_type is None: + raise TypeTransformerFailedError(f"Cannot convert literal {lv} to None") + return None + + +def _register_default_type_transformers(): + TypeEngine.register( + SimpleTransformer( + "int", + int, + _type_models.LiteralType(simple=_type_models.SimpleType.INTEGER), + lambda x: Literal(scalar=Scalar(primitive=Primitive(integer=x))), + lambda x: x.scalar.primitive.integer, + ) + ) + + TypeEngine.register( + SimpleTransformer( + "float", + float, + _type_models.LiteralType(simple=_type_models.SimpleType.FLOAT), + lambda x: Literal(scalar=Scalar(primitive=Primitive(float_value=x))), + _check_and_covert_float, + ) + ) + + TypeEngine.register( + SimpleTransformer( + "bool", + bool, + _type_models.LiteralType(simple=_type_models.SimpleType.BOOLEAN), + lambda x: Literal(scalar=Scalar(primitive=Primitive(boolean=x))), + lambda x: x.scalar.primitive.boolean, + ) + ) + + TypeEngine.register( + SimpleTransformer( + "str", + str, + _type_models.LiteralType(simple=_type_models.SimpleType.STRING), + lambda x: Literal(scalar=Scalar(primitive=Primitive(string_value=x))), + lambda x: x.scalar.primitive.string_value, + ) + ) + + TypeEngine.register( + SimpleTransformer( + "datetime", + _datetime.datetime, + _type_models.LiteralType(simple=_type_models.SimpleType.DATETIME), + lambda x: Literal(scalar=Scalar(primitive=Primitive(datetime=x))), + lambda x: x.scalar.primitive.datetime, + ) + ) + + TypeEngine.register( + SimpleTransformer( + "timedelta", + _datetime.timedelta, + _type_models.LiteralType(simple=_type_models.SimpleType.DURATION), + lambda x: Literal(scalar=Scalar(primitive=Primitive(duration=x))), + lambda x: x.scalar.primitive.duration, + ) + ) + + TypeEngine.register( + SimpleTransformer( + "date", + _datetime.date, + _type_models.LiteralType(simple=_type_models.SimpleType.DATETIME), + lambda x: Literal( + scalar=Scalar(primitive=Primitive(datetime=_datetime.datetime.combine(x, _datetime.time.min))) + ), # convert datetime to date + lambda x: x.scalar.primitive.datetime.date(), # get date from datetime + ) + ) + + TypeEngine.register( + SimpleTransformer( + "none", + type(None), + _type_models.LiteralType(simple=_type_models.SimpleType.NONE), + lambda x: Literal(scalar=Scalar(none_type=Void())), + lambda x: _check_and_convert_void(x), + ), + [None], + ) + TypeEngine.register(ListTransformer()) + TypeEngine.register(UnionTransformer()) + TypeEngine.register(DictTransformer()) + TypeEngine.register(TextIOTransformer()) + TypeEngine.register(BinaryIOTransformer()) + TypeEngine.register(EnumTransformer()) + TypeEngine.register(ProtobufTransformer()) + + # inner type is. Also unsupported are typing's Tuples. Even though you can look inside them, Flyte's type system + # doesn't support these currently. + # Confusing note: typing.NamedTuple is in here even though task functions themselves can return them. We just mean + # that the return signature of a task can be a NamedTuple that contains another NamedTuple inside it. + # Also, it's not entirely true that Flyte IDL doesn't support tuples. We can always fake them as structs, but we'll + # hold off on doing that for now, as we may amend the IDL formally to support tuples. + TypeEngine.register_restricted_type("non typed tuple", tuple) + TypeEngine.register_restricted_type("non typed tuple", typing.Tuple) + TypeEngine.register_restricted_type("named tuple", NamedTuple) + + +class LiteralsResolver(collections.UserDict): + """ + LiteralsResolver is a helper class meant primarily for use with the FlyteRemote experience or any other situation + where you might be working with LiteralMaps. This object allows the caller to specify the Python type that should + correspond to an element of the map. + + TODO: Consider inheriting from collections.UserDict instead of manually having the _native_values cache + """ + + def __init__( + self, + literals: typing.Dict[str, Literal], + variable_map: Optional[Dict[str, _interface_models.Variable]] = None, + ctx: Optional[FlyteContext] = None, + ): + """ + :param literals: A Python map of strings to Flyte Literal models. + :param variable_map: This map should be basically one side (either input or output) of the Flyte + TypedInterface model and is used to guess the Python type through the TypeEngine if a Python type is not + specified by the user. TypeEngine guessing is flaky though, so calls to get() should specify the as_type + parameter when possible. + """ + super().__init__(literals) + if literals is None: + raise ValueError("Cannot instantiate LiteralsResolver without a map of Literals.") + self._literals = literals + self._variable_map = variable_map + self._native_values: Dict[str, type] = {} + self._type_hints: Dict[str, type] = {} + self._ctx = ctx + + def __str__(self) -> str: + if self.literals: + if len(self.literals) == len(self.native_values): + return str(self.native_values) + if self.native_values: + header = "Partially converted to native values, call get(key, ) to convert rest...\n" + strs = [] + for key, literal in self._literals.items(): + if key in self._native_values: + strs.append(f"{key}: " + str(self._native_values[key]) + "\n") + else: + lit_txt = str(self._literals[key]) + lit_txt = textwrap.indent(lit_txt, " " * (len(key) + 2)) + strs.append(f"{key}: \n" + lit_txt) + + return header + "{\n" + textwrap.indent("".join(strs), " " * 2) + "\n}" + else: + return str(literal_map_string_repr(self.literals)) + return "{}" + + def __repr__(self): + return self.__str__() + + @property + def native_values(self) -> typing.Dict[str, typing.Any]: + return self._native_values + + @property + def variable_map(self) -> Optional[Dict[str, _interface_models.Variable]]: + return self._variable_map + + @property + def literals(self): + return self._literals + + def update_type_hints(self, type_hints: typing.Dict[str, typing.Type]): + self._type_hints.update(type_hints) + + def get_literal(self, key: str) -> Literal: + if key not in self._literals: + raise ValueError(f"Key {key} is not in the literal map") + + return self._literals[key] + + def __getitem__(self, key: str): + # First check to see if it's even in the literal map. + if key not in self._literals: + raise ValueError(f"Key {key} is not in the literal map") + + # Return the cached value if it's cached + if key in self._native_values: + return self._native_values[key] + + return self.get(key) + + def get(self, attr: str, as_type: Optional[typing.Type] = None) -> typing.Any: # type: ignore + """ + This will get the ``attr`` value from the Literal map, and invoke the TypeEngine to convert it into a Python + native value. A Python type can optionally be supplied. If successful, the native value will be cached and + future calls will return the cached value instead. + + :param attr: + :param as_type: + :return: Python native value from the LiteralMap + """ + if attr not in self._literals: + raise AttributeError(f"Attribute {attr} not found") + if attr in self.native_values: + return self.native_values[attr] + + if as_type is None: + if attr in self._type_hints: + as_type = self._type_hints[attr] + else: + if self.variable_map and attr in self.variable_map: + try: + as_type = TypeEngine.guess_python_type(self.variable_map[attr].type) + except ValueError as e: + logger.error(f"Could not guess a type for Variable {self.variable_map[attr]}") + raise e + else: + raise ValueError("as_type argument not supplied and Variable map not specified in LiteralsResolver") + val = TypeEngine.to_python_value( + self._ctx or FlyteContext.current_context(), self._literals[attr], cast(Type, as_type) + ) + self._native_values[attr] = val + return val + + +_register_default_type_transformers() + + +def is_annotated(t: Type) -> bool: + return get_origin(t) is Annotated + + +def get_underlying_type(t: Type) -> Type: + """Return the underlying type for annotated types or the type itself""" + if is_annotated(t): + return get_args(t)[0] + return t diff --git a/flytekit/flytekit/core/type_helpers.py b/flytekit/flytekit/core/type_helpers.py new file mode 100644 index 0000000000..48286b2063 --- /dev/null +++ b/flytekit/flytekit/core/type_helpers.py @@ -0,0 +1,26 @@ +import importlib +import typing + +T = typing.TypeVar("T") + + +def load_type_from_tag(tag: str) -> typing.Type[T]: + """ + Loads python type from tag + """ + + if "." not in tag: + raise ValueError( + f"Protobuf tag must include at least one '.' to delineate package and object name got {tag}", + ) + + module, name = tag.rsplit(".", 1) + try: + pb_module = importlib.import_module(module) + except ImportError: + raise ValueError(f"Could not resolve the protobuf definition @ {module}. Is the protobuf library installed?") + + if not hasattr(pb_module, name): + raise ValueError(f"Could not find the protobuf named: {name} @ {module}.") + + return getattr(pb_module, name) diff --git a/flytekit/flytekit/core/utils.py b/flytekit/flytekit/core/utils.py new file mode 100644 index 0000000000..17cdfb3de9 --- /dev/null +++ b/flytekit/flytekit/core/utils.py @@ -0,0 +1,392 @@ +import datetime +import os as _os +import shutil as _shutil +import tempfile as _tempfile +import time as _time +from abc import ABC, abstractmethod +from functools import wraps +from hashlib import sha224 as _sha224 +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, cast + +from flyteidl.core import tasks_pb2 as _core_task + +from flytekit.configuration import SerializationSettings +from flytekit.core.pod_template import PodTemplate +from flytekit.loggers import logger + +if TYPE_CHECKING: + from flytekit.models import task as task_models + + +def _dnsify(value: str) -> str: + """ + Converts value into a DNS-compliant (RFC1035/RFC1123 DNS_LABEL). The resulting string must only consist of + alphanumeric (lower-case a-z, and 0-9) and not exceed 63 characters. It's permitted to have '-' character as long + as it's not in the first or last positions. + + :param Text value: + :rtype: Text + """ + res = "" + MAX = 63 + HASH_LEN = 10 + if len(value) >= MAX: + h = _sha224(value.encode("utf-8")).hexdigest()[:HASH_LEN] + value = "{}-{}".format(h, value[-(MAX - HASH_LEN - 1) :]) + for ch in value: + if ch == "_" or ch == "-" or ch == ".": + # Convert '_' to '-' unless it's the first character, in which case we drop it. + if res != "" and len(res) < 62: + res += "-" + elif not ch.isalnum(): + # Trim non-alphanumeric letters. + pass + elif ch.islower() or ch.isdigit(): + # Character is already compliant, just append it. + res += ch + else: + # Character is upper-case. Add a '-' before it for better readability. + if res != "" and res[-1] != "-" and len(res) < 62: + res += "-" + res += ch.lower() + + if len(res) > 0 and res[-1] == "-": + res = res[: len(res) - 1] + + return res + + +def _get_container_definition( + image: str, + command: List[str], + args: Optional[List[str]] = None, + data_loading_config: Optional["task_models.DataLoadingConfig"] = None, + ephemeral_storage_request: Optional[str] = None, + cpu_request: Optional[str] = None, + gpu_request: Optional[str] = None, + memory_request: Optional[str] = None, + ephemeral_storage_limit: Optional[str] = None, + cpu_limit: Optional[str] = None, + gpu_limit: Optional[str] = None, + memory_limit: Optional[str] = None, + environment: Optional[Dict[str, str]] = None, +) -> "task_models.Container": + ephemeral_storage_limit = ephemeral_storage_limit + ephemeral_storage_request = ephemeral_storage_request + cpu_limit = cpu_limit + cpu_request = cpu_request + gpu_limit = gpu_limit + gpu_request = gpu_request + 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 ephemeral_storage_request: + requests.append( + 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)) + if 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)) + + limits = [] + if ephemeral_storage_limit: + limits.append( + 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)) + if 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)) + + if environment is None: + environment = {} + + return task_models.Container( + image=image, + command=command, + args=args, + 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", + settings: SerializationSettings, +) -> Dict[str, Any]: + # import here to avoid circular import + from kubernetes.client import ApiClient, V1PodSpec + from kubernetes.client.models import V1Container, V1EnvVar, V1ResourceRequirements + + from flytekit.core.python_auto_container import get_registerable_container_image + + 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) + + # resolve the image name if it is image spec or placeholder + resolved_image = get_registerable_container_image(container.image, settings.image_config) + + if container.name == cast(PodTemplate, pod_template).primary_container_name: + if container.image is None: + # Copy the image from primary_container only if the image is not specified in the pod spec. + container.image = primary_container.image + else: + container.image = resolved_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 [] + ) + else: + container.image = resolved_image + + 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() + out.ParseFromString(reader.read()) + return out + + +def write_proto_to_file(proto, path): + Path(_os.path.dirname(path)).mkdir(parents=True, exist_ok=True) + with open(path, "wb") as writer: + writer.write(proto.SerializeToString()) + + +class Directory(object): + def __init__(self, path): + """ + :param Text path: local path of directory + """ + self._name = path + + @property + def name(self): + """ + :rtype: Text + """ + return self._name + + def list_dir(self): + """ + The list of absolute filepaths for all immediate sub-paths + :rtype: list[Text] + """ + return [_os.path.join(self.name, f) for f in _os.listdir(self.name)] + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + +class AutoDeletingTempDir(Directory): + """ + Creates a posix safe tempdir which is auto deleted once out of scope + """ + + def __init__(self, working_dir_prefix=None, tmp_dir=None, cleanup=True): + """ + :param Text working_dir_prefix: A prefix to help identify temporary directories + :param Text tmp_dir: Path to desired temporary directory + :param bool cleanup: Whether the directory should be cleaned up upon exit + """ + self._tmp_dir = tmp_dir + self._working_dir_prefix = (working_dir_prefix + "_") if working_dir_prefix else "" + self._cleanup = cleanup + super(AutoDeletingTempDir, self).__init__(None) + + def __enter__(self): + self._name = _tempfile.mkdtemp(dir=self._tmp_dir, prefix=self._working_dir_prefix) + return self + + def get_named_tempfile(self, name): + return _os.path.join(self.name, name) + + def _cleanup_dir(self): + if self.name and self._cleanup: + if _os.path.exists(self.name): + _shutil.rmtree(self.name) + self._name = None + + def force_cleanup(self): + self._cleanup_dir() + + def __exit__(self, exc_type, exc_val, exc_tb): + self._cleanup_dir() + + def __repr__(self): + return "Auto-Deleting Tmp Directory @ {}".format(self.name) + + def __str__(self): + return self.__repr__() + + +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 name: A string that describes the wrapped code block or function being executed. + """ + 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): + 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( + "{}. [Wall Time: {}s, Process Time: {}s]".format( + self._name, + end_wall_time - self._start_wall_time, + end_process_time - self._start_process_time, + ) + ) + + +class ClassDecorator(ABC): + """ + Abstract class for class decorators. + We can attach config on the decorator class and use it in the upper level. + """ + + LINK_TYPE_KEY = "link_type" + PORT_KEY = "port" + + def __init__(self, task_function=None, **kwargs): + """ + If the decorator is called with arguments, func will be None. + If the decorator is called without arguments, func will be function to be decorated. + """ + self.task_function = task_function + self.decorator_kwargs = kwargs + if task_function: + # wraps preserve the function metadata, including type annotations, from the original function to the decorator. + wraps(task_function)(self) + + def __call__(self, *args, **kwargs): + if self.task_function: + # Where the actual execution happens. + return self.execute(*args, **kwargs) + else: + # If self.func is None, it means decorator was called with arguments. + # Therefore, __call__ received the actual function to be decorated. + # We return a new instance of ClassDecorator with the function and stored arguments. + return self.__class__(args[0], **self.decorator_kwargs) + + @abstractmethod + def execute(self, *args, **kwargs): + """ + This method will be called when the decorated function is called. + """ + pass + + @abstractmethod + def get_extra_config(self): + """ + Get the config of the decorator. + """ + pass diff --git a/flytekit/flytekit/core/workflow.py b/flytekit/flytekit/core/workflow.py new file mode 100644 index 0000000000..108b323a48 --- /dev/null +++ b/flytekit/flytekit/core/workflow.py @@ -0,0 +1,916 @@ +from __future__ import annotations + +import asyncio +import inspect +import typing +from dataclasses import dataclass +from enum import Enum +from functools import update_wrapper +from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Type, Union, cast, overload + +from flytekit.core import constants as _common_constants +from flytekit.core import launch_plan as _annotated_launch_plan +from flytekit.core.base_task import PythonTask, Task +from flytekit.core.class_based_resolver import ClassStorageTaskResolver +from flytekit.core.condition import ConditionalSection, conditional +from flytekit.core.context_manager import ( + CompilationState, + ExecutionState, + FlyteContext, + FlyteContextManager, + FlyteEntities, +) +from flytekit.core.docstring import Docstring +from flytekit.core.interface import ( + Interface, + transform_function_to_interface, + transform_interface_to_typed_interface, +) +from flytekit.core.node import Node +from flytekit.core.promise import ( + NodeOutput, + Promise, + VoidPromise, + binding_from_python_std, + create_task_output, + extract_obj_name, + flyte_entity_call_handler, + translate_inputs_to_literals, +) +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.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.core import workflow as _workflow_model +from flytekit.models.documentation import Description, Documentation +from flytekit.types.error import FlyteError + +GLOBAL_START_NODE = Node( + id=_common_constants.GLOBAL_INPUT_NODE_ID, + metadata=None, + bindings=[], + upstream_nodes=[], + flyte_entity=None, +) + +T = typing.TypeVar("T") +FuncOut = typing.TypeVar("FuncOut") + + +class WorkflowFailurePolicy(Enum): + """ + Defines the behavior for a workflow execution in the case of an observed node execution failure. By default, a + workflow execution will immediately enter a failed state if a component node fails. + """ + + #: Causes the entire workflow execution to fail once a component node fails. + FAIL_IMMEDIATELY = _workflow_model.WorkflowMetadata.OnFailurePolicy.FAIL_IMMEDIATELY + + #: Will proceed to run any remaining runnable nodes once a component node fails. + FAIL_AFTER_EXECUTABLE_NODES_COMPLETE = ( + _workflow_model.WorkflowMetadata.OnFailurePolicy.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE + ) + + +@dataclass +class WorkflowMetadata(object): + on_failure: WorkflowFailurePolicy + + def __post_init__(self): + if ( + self.on_failure != WorkflowFailurePolicy.FAIL_IMMEDIATELY + and self.on_failure != WorkflowFailurePolicy.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE + ): + raise FlyteValidationException(f"Failure policy {self.on_failure} not acceptable") + + def to_flyte_model(self): + if self.on_failure == WorkflowFailurePolicy.FAIL_IMMEDIATELY: + on_failure = 0 + else: + on_failure = 1 + return _workflow_model.WorkflowMetadata(on_failure=on_failure) + + +@dataclass +class WorkflowMetadataDefaults(object): + """ + This class is similarly named to the one above. Please see the IDL for more information but essentially, this + WorkflowMetadataDefaults class represents the defaults that are handed down to a workflow's tasks, whereas + WorkflowMetadata represents metadata about the workflow itself. + """ + + interruptible: bool + + def __post_init__(self): + # TODO: Get mypy working so we don't have to worry about these checks + if self.interruptible is not True and self.interruptible is not False: + raise FlyteValidationException(f"Interruptible must be boolean, {self.interruptible} invalid") + + def to_flyte_model(self): + return _workflow_model.WorkflowMetadataDefaults(interruptible=self.interruptible) + + +def construct_input_promises(inputs: List[str]) -> Dict[str, Promise]: + return { + input_name: Promise(var=input_name, val=NodeOutput(node=GLOBAL_START_NODE, var=input_name)) + for input_name in inputs + } + + +def get_promise(binding_data: _literal_models.BindingData, outputs_cache: Dict[Node, Dict[str, Promise]]) -> Promise: + """ + This is a helper function that will turn a binding into a Promise object, using a lookup map. Please see + get_promise_map for the rest of the details. + """ + if binding_data.promise is not None: + if not isinstance(binding_data.promise, NodeOutput): + raise FlyteValidationException( + f"Binding data Promises have to be of the NodeOutput type {type(binding_data.promise)} found" + ) + # b.var is the name of the input to the task + # binding_data.promise.var is the name of the upstream node's output we want + return outputs_cache[binding_data.promise.node][binding_data.promise.var] + elif binding_data.scalar is not None: + return Promise(var="placeholder", val=_literal_models.Literal(scalar=binding_data.scalar)) + elif binding_data.collection is not None: + literals = [] + for bd in binding_data.collection.bindings: + p = get_promise(bd, outputs_cache) + literals.append(p.val) + return Promise( + var="placeholder", + val=_literal_models.Literal(collection=_literal_models.LiteralCollection(literals=literals)), + ) + elif binding_data.map is not None: + literals = {} # type: ignore + for k, bd in binding_data.map.bindings.items(): + p = get_promise(bd, outputs_cache) + literals[k] = p.val + return Promise( + var="placeholder", val=_literal_models.Literal(map=_literal_models.LiteralMap(literals=literals)) + ) + + raise FlyteValidationException("Binding type unrecognized.") + + +def get_promise_map( + bindings: List[_literal_models.Binding], outputs_cache: Dict[Node, Dict[str, Promise]] +) -> Dict[str, Promise]: + """ + Local execution of imperatively defined workflows is done node by node. This function will fill in the node's + entity's input arguments, which are specified using the bindings list, and a map of nodes to its outputs. + Basically this takes the place of propeller in resolving bindings, pulling in outputs from previously completed + nodes and filling in the necessary inputs. + """ + entity_kwargs = {} + for b in bindings: + entity_kwargs[b.var] = get_promise(b.binding, outputs_cache) + + return entity_kwargs + + +class WorkflowBase(object): + def __init__( + self, + name: str, + workflow_metadata: WorkflowMetadata, + workflow_metadata_defaults: WorkflowMetadataDefaults, + python_interface: Interface, + on_failure: Optional[Union[WorkflowBase, Task]] = None, + docs: Optional[Documentation] = None, + **kwargs, + ): + self._name = name + self._workflow_metadata = workflow_metadata + self._workflow_metadata_defaults = workflow_metadata_defaults + self._python_interface = python_interface + self._interface = transform_interface_to_typed_interface(python_interface) + self._inputs: Dict[str, Promise] = {} + self._unbound_inputs: typing.Set[Promise] = set() + self._nodes: List[Node] = [] + self._output_bindings: List[_literal_models.Binding] = [] + self._on_failure = on_failure + self._failure_node = None + self._docs = docs + + if self._python_interface.docstring: + if self.docs is None: + self._docs = Documentation( + short_description=self._python_interface.docstring.short_description, + long_description=Description(value=self._python_interface.docstring.long_description), + ) + else: + if self._python_interface.docstring.short_description: + cast( + Documentation, self._docs + ).short_description = self._python_interface.docstring.short_description + if self._python_interface.docstring.long_description: + self._docs = Description(value=self._python_interface.docstring.long_description) + + FlyteEntities.entities.append(self) + super().__init__(**kwargs) + + @property + def name(self) -> str: + return self._name + + @property + def docs(self): + return self._docs + + @property + def short_name(self) -> str: + return extract_obj_name(self._name) + + @property + def workflow_metadata(self) -> WorkflowMetadata: + return self._workflow_metadata + + @property + def workflow_metadata_defaults(self) -> WorkflowMetadataDefaults: + return self._workflow_metadata_defaults + + @property + def python_interface(self) -> Interface: + return self._python_interface + + @property + def interface(self) -> _interface_models.TypedInterface: + return self._interface + + @property + def output_bindings(self) -> List[_literal_models.Binding]: + self.compile() + return self._output_bindings + + @property + def nodes(self) -> List[Node]: + self.compile() + return self._nodes + + @property + def on_failure(self) -> Optional[Union[WorkflowBase, Task]]: + return self._on_failure + + @property + def failure_node(self) -> Optional[Node]: + return self._failure_node + + def __repr__(self): + return ( + f"WorkflowBase - {self._name} && " + f"Inputs ({len(self._python_interface.inputs)}): {self._python_interface.inputs} && " + f"Outputs ({len(self._python_interface.outputs)}): {self._python_interface.outputs} && " + f"Output bindings: {self._output_bindings} && " + ) + + def construct_node_metadata(self) -> _workflow_model.NodeMetadata: + return _workflow_model.NodeMetadata( + name=extract_obj_name(self.name), + interruptible=self.workflow_metadata_defaults.interruptible, + ) + + def __call__(self, *args, **kwargs) -> Union[Tuple[Promise], Promise, VoidPromise, Tuple, Coroutine, None]: + """ + Workflow needs to fill in default arguments before invoking the call handler. + """ + # Get default arguments and override with kwargs passed in + input_kwargs = self.python_interface.default_inputs_as_kwargs + input_kwargs.update(kwargs) + self.compile() + try: + return flyte_entity_call_handler(self, *args, **input_kwargs) + except Exception as exc: + if self.on_failure: + if self.on_failure.python_interface and "err" in self.on_failure.python_interface.inputs: + input_kwargs["err"] = FlyteError(failed_node_id="", message=str(exc)) + self.on_failure(**input_kwargs) + raise exc + + def execute(self, **kwargs): + raise Exception("Should not be called") + + def compile(self, **kwargs): + pass + + 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. + literal_map = translate_inputs_to_literals( + ctx, + incoming_values=kwargs, + flyte_interface_types=self.interface.inputs, + native_types=self.python_interface.inputs, + ) + kwargs_literals = {k: Promise(var=k, val=v) for k, v in literal_map.items()} + self.compile() + function_outputs = self.execute(**kwargs_literals) + + if inspect.iscoroutine(function_outputs): + # handle coroutines for eager workflows + function_outputs = asyncio.run(function_outputs) + + # First handle the empty return case. + # A workflow function may return a task that doesn't return anything + # def wf(): + # return t1() + # or it may not return at all + # def wf(): + # t1() + # In the former case we get the task's VoidPromise, in the latter we get None + if isinstance(function_outputs, VoidPromise) or function_outputs is None: + if len(self.python_interface.outputs) != 0: + raise FlyteValueException( + function_outputs, + f"Interface has {len(self.python_interface.outputs)} outputs.", + ) + return VoidPromise(self.name) + + # Because we should've already returned in the above check, we just raise an error here. + if len(self.python_interface.outputs) == 0: + raise FlyteValueException(function_outputs, "Interface output should've been VoidPromise or None.") + + expected_output_names = list(self.python_interface.outputs.keys()) + if len(expected_output_names) == 1: + # Here we have to handle the fact that the wf could've been declared with a typing.NamedTuple of + # length one. That convention is used for naming outputs - and single-length-NamedTuples are + # particularly troublesome but elegant handling of them is not a high priority + # Again, we're using the output_tuple_name as a proxy. + if self.python_interface.output_tuple_name and isinstance(function_outputs, tuple): + wf_outputs_as_map = {expected_output_names[0]: function_outputs[0]} + else: + wf_outputs_as_map = {expected_output_names[0]: function_outputs} + else: + wf_outputs_as_map = {expected_output_names[i]: function_outputs[i] for i, _ in enumerate(function_outputs)} + + # Basically we need to repackage the promises coming from the tasks into Promises that match the workflow's + # interface. We do that by extracting out the literals, and creating new Promises + wf_outputs_as_literal_dict = translate_inputs_to_literals( + ctx, + wf_outputs_as_map, + flyte_interface_types=self.interface.outputs, + native_types=self.python_interface.outputs, + ) + # Recreate new promises that use the workflow's output names. + new_promises = [Promise(var, wf_outputs_as_literal_dict[var]) for var in expected_output_names] + + return create_task_output(new_promises, self.python_interface) + + def local_execution_mode(self) -> ExecutionState.Mode: + """ """ + return ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION + + +class ImperativeWorkflow(WorkflowBase): + """ + An imperative workflow is a programmatic analogue to the typical ``@workflow`` function-based workflow and is + better suited to programmatic applications. + + Assuming you have some tasks like so + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_imperative.py + :start-after: # docs_tasks_start + :end-before: # docs_tasks_end + :language: python + :dedent: 4 + + You could create a workflow imperatively like so + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_imperative.py + :start-after: # docs_start + :end-before: # docs_end + :language: python + :dedent: 4 + + This workflow would be identical on the back-end to + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_imperative.py + :start-after: # docs_equivalent_start + :end-before: # docs_equivalent_end + :language: python + :dedent: 4 + + Note that the only reason we need the ``NamedTuple`` is so we can name the output the same thing as in the + imperative example. The imperative paradigm makes the naming of workflow outputs easier, but this isn't a big + deal in function-workflows because names tend to not be necessary. + """ + + def __init__( + self, + name: str, + failure_policy: Optional[WorkflowFailurePolicy] = None, + interruptible: bool = False, + ): + metadata = WorkflowMetadata(on_failure=failure_policy or WorkflowFailurePolicy.FAIL_IMMEDIATELY) + workflow_metadata_defaults = WorkflowMetadataDefaults(interruptible) + self._compilation_state = CompilationState(prefix="") + self._inputs = {} + # This unbound inputs construct is just here to help workflow authors detect issues a bit earlier. It just + # keeps track of workflow inputs that you've declared with add_workflow_input but haven't yet consumed. This + # is an error that Admin would return at compile time anyways, but this allows flytekit to raise + # the error earlier. + self._unbound_inputs = set() + super().__init__( + name=name, + workflow_metadata=metadata, + workflow_metadata_defaults=workflow_metadata_defaults, + python_interface=Interface(), + ) + + @property + def compilation_state(self) -> CompilationState: + """ + Compilation is done a bit at a time, one task or other entity call at a time. This is why this workflow + class has to keep track of its own compilation state. + """ + return self._compilation_state + + @property + def nodes(self) -> List[Node]: + return self._compilation_state.nodes + + @property + def inputs(self) -> Dict[str, Promise]: + """ + This holds the input promises to the workflow. The nodes in these Promise objects should always point to + the global start node. + """ + return self._inputs + + def __repr__(self): + return super().__repr__() + f"Nodes ({len(self.compilation_state.nodes)}): {self.compilation_state.nodes}" + + def execute(self, **kwargs): + """ + Called by local_execute. This function is how local execution for imperative workflows runs. Because when an + entity is added using the add_entity function, all inputs to that entity should've been already declared, we + can just iterate through the nodes in order and we shouldn't run into any dependency issues. That is, we force + the user to declare entities already in a topological sort. To keep track of outputs, we create a map to + start things off, filled in only with the workflow inputs (if any). As things are run, their outputs are stored + in this map. + After all nodes are run, we fill in workflow level outputs the same way as any other previous node. + """ + if not self.ready(): + raise FlyteValidationException(f"Workflow not ready, wf is currently {self}") + + # Create a map that holds the outputs of each node. + intermediate_node_outputs: Dict[Node, Dict[str, Promise]] = {GLOBAL_START_NODE: {}} # type: ignore + + # Start things off with the outputs of the global input node, i.e. the inputs to the workflow. + # local_execute should've already ensured that all the values in kwargs are Promise objects + for k, v in kwargs.items(): + intermediate_node_outputs[GLOBAL_START_NODE][k] = v + + # Next iterate through the nodes in order. + for node in self.compilation_state.nodes: + if node not in intermediate_node_outputs.keys(): + intermediate_node_outputs[node] = {} + + # Retrieve the entity from the node, and call it by looking up the promises the node's bindings require, + # and then fill them in using the node output tracker map we have. + entity = node.flyte_entity + entity_kwargs = get_promise_map(node.bindings, intermediate_node_outputs) + + # Handle the calling and outputs of each node's entity + results = entity(**entity_kwargs) + expected_output_names = list(entity.python_interface.outputs.keys()) + + if isinstance(results, VoidPromise) or results is None: + continue # pragma: no cover # Move along, nothing to assign + + # Because we should've already returned in the above check, we just raise an Exception here. + if len(entity.python_interface.outputs) == 0: + raise FlyteValueException(results, "Interface output should've been VoidPromise or None.") + + # if there's only one output, + if len(expected_output_names) == 1: + if entity.python_interface.output_tuple_name and isinstance(results, tuple): + intermediate_node_outputs[node][expected_output_names[0]] = results[0] + else: + intermediate_node_outputs[node][expected_output_names[0]] = results + + else: + if len(results) != len(expected_output_names): + raise FlyteValueException(results, f"Different lengths {results} {expected_output_names}") + for idx, r in enumerate(results): + intermediate_node_outputs[node][expected_output_names[idx]] = r + + # The rest of this function looks like the above but now we're doing it for the workflow as a whole rather + # than just one node at a time. + if len(self.python_interface.outputs) == 0: + return VoidPromise(self.name) + + # The values that we return below from the output have to be pulled by fulfilling all of the + # workflow's output bindings. + # The return style here has to match what 1) what the workflow would've returned had it been declared + # functionally, and 2) what a user would return in mock function. That is, if it's a tuple, then it + # should be a tuple here, if it's a one element named tuple, then we do a one-element non-named tuple, + # if it's a single element then we return a single element + if len(self.output_bindings) == 1: + # Again use presence of output_tuple_name to understand that we're dealing with a one-element + # named tuple + if self.python_interface.output_tuple_name: + return (get_promise(self.output_bindings[0].binding, intermediate_node_outputs),) + # Just a normal single element + return get_promise(self.output_bindings[0].binding, intermediate_node_outputs) + return tuple([get_promise(b.binding, intermediate_node_outputs) for b in self.output_bindings]) + + def create_conditional(self, name: str) -> ConditionalSection: + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None: + raise Exception("Can't already be compiling") + FlyteContextManager.with_context(ctx.with_compilation_state(self.compilation_state)) + return conditional(name=name) + + def add_entity(self, entity: Union[PythonTask, _annotated_launch_plan.LaunchPlan, WorkflowBase], **kwargs) -> Node: + """ + Anytime you add an entity, all the inputs to the entity must be bound. + """ + # circular import + from flytekit.core.node_creation import create_node + + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None: + raise Exception("Can't already be compiling") + with FlyteContextManager.with_context(ctx.with_compilation_state(self.compilation_state)) as ctx: + n = create_node(entity=entity, **kwargs) + + def get_input_values(input_value): + if isinstance(input_value, list): + input_promises = [] + for x in input_value: + input_promises.extend(get_input_values(x)) + return input_promises + if isinstance(input_value, dict): + input_promises = [] + for _, v in input_value.items(): + input_promises.extend(get_input_values(v)) + return input_promises + else: + return [input_value] + + # Every time an entity is added, mark it as used. The above function though will gather all the input + # values but we're only interested in the ones that are Promises so let's filter for those. + # There's probably a way to clean this up, maybe key off of the name instead of value? + all_input_values = get_input_values(kwargs) + for input_value in filter(lambda x: isinstance(x, Promise), all_input_values): + if input_value in self._unbound_inputs: + self._unbound_inputs.remove(input_value) + return n # type: ignore + + def add_workflow_input(self, input_name: str, python_type: Type) -> Promise: + """ + Adds an input to the workflow. + """ + if input_name in self._inputs: + raise FlyteValidationException(f"Input {input_name} has already been specified for wf {self.name}.") + self._python_interface = self._python_interface.with_inputs(extra_inputs={input_name: python_type}) + self._interface = transform_interface_to_typed_interface(self._python_interface) + self._inputs[input_name] = Promise(var=input_name, val=NodeOutput(node=GLOBAL_START_NODE, var=input_name)) + self._unbound_inputs.add(self._inputs[input_name]) + return self._inputs[input_name] + + def add_workflow_output( + self, output_name: str, p: Union[Promise, List[Promise], Dict[str, Promise]], python_type: Optional[Type] = None + ): + """ + Add an output with the given name from the given node output. + """ + if output_name in self._python_interface.outputs: + raise FlyteValidationException(f"Output {output_name} already exists in workflow {self.name}") + + if python_type is None: + if type(p) == list or type(p) == dict: + raise FlyteValidationException( + f"If specifying a list or dict of Promises, you must specify the python_type type for {output_name}" + f" starting with the container type (e.g. List[int]" + ) + promise = cast(Promise, p) + python_type = promise.ref.node.flyte_entity.python_interface.outputs[promise.var] + logger.debug(f"Inferring python type for wf output {output_name} from Promise provided {python_type}") + + flyte_type = TypeEngine.to_literal_type(python_type=python_type) + + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None: + raise Exception("Can't already be compiling") + with FlyteContextManager.with_context(ctx.with_compilation_state(self.compilation_state)) as ctx: + b, _ = binding_from_python_std( + ctx, output_name, expected_literal_type=flyte_type, t_value=p, t_value_type=python_type + ) + self._output_bindings.append(b) + self._python_interface = self._python_interface.with_outputs(extra_outputs={output_name: python_type}) + self._interface = transform_interface_to_typed_interface(self._python_interface) + + def add_task(self, task: PythonTask, **kwargs) -> Node: + return self.add_entity(task, **kwargs) + + def add_launch_plan(self, launch_plan: _annotated_launch_plan.LaunchPlan, **kwargs) -> Node: + return self.add_entity(launch_plan, **kwargs) + + def add_subwf(self, sub_wf: WorkflowBase, **kwargs) -> Node: + return self.add_entity(sub_wf, **kwargs) + + def ready(self) -> bool: + """ + This function returns whether or not the workflow is in a ready state, which means + * Has at least one node + * All workflow inputs are bound + + These conditions assume that all nodes and workflow i/o changes were done with the functions above, which + do additional checking. + """ + if len(self.compilation_state.nodes) == 0: + return False + + if len(self._unbound_inputs) > 0: + return False + + return True + + +class PythonFunctionWorkflow(WorkflowBase, ClassStorageTaskResolver): + """ + Please read :std:ref:`flyte:divedeep-workflows` first for a high-level understanding of what workflows are in Flyte. + This Python object represents a workflow defined by a function and decorated with the + :py:func:`@workflow ` decorator. Please see notes on that object for additional information. + """ + + def __init__( + self, + workflow_function: Callable, + metadata: WorkflowMetadata, + default_metadata: WorkflowMetadataDefaults, + docstring: Optional[Docstring] = None, + on_failure: Optional[Union[WorkflowBase, Task]] = None, + docs: Optional[Documentation] = None, + ): + name, _, _, _ = extract_task_module(workflow_function) + self._workflow_function = workflow_function + native_interface = transform_function_to_interface(workflow_function, docstring=docstring) + + # TODO do we need this - can this not be in launchplan only? + # This can be in launch plan only, but is here only so that we don't have to re-evaluate. Or + # we can re-evaluate. + self._input_parameters = None + super().__init__( + name=name, + workflow_metadata=metadata, + workflow_metadata_defaults=default_metadata, + python_interface=native_interface, + on_failure=on_failure, + docs=docs, + ) + self.compiled = False + + @property + def function(self): + return self._workflow_function + + def task_name(self, t: PythonAutoContainerTask) -> str: # type: ignore + return f"{self.name}.{t.__module__}.{t.name}" + + def _validate_add_on_failure_handler(self, ctx: FlyteContext, prefix: str, wf_args: Dict[str, Promise]): + # Compare + with FlyteContextManager.with_context( + ctx.with_compilation_state(CompilationState(prefix=prefix, task_resolver=self)) + ) as inner_comp_ctx: + # Now lets compile the failure-node if it exists + if self.on_failure: + c = wf_args.copy() + exception_scopes.user_entry_point(self.on_failure)(**c) + inner_nodes = None + if inner_comp_ctx.compilation_state and inner_comp_ctx.compilation_state.nodes: + inner_nodes = inner_comp_ctx.compilation_state.nodes + if not inner_nodes or len(inner_nodes) > 1: + raise AssertionError("Unable to compile failure node, only either a task or a workflow can be used") + self._failure_node = inner_nodes[0] + + def compile(self, **kwargs): + """ + Supply static Python native values in the kwargs if you want them to be used in the compilation. This mimics + a 'closure' in the traditional sense of the word. + """ + if self.compiled: + return + + self.compiled = True + ctx = FlyteContextManager.current_context() + all_nodes = [] + prefix = ctx.compilation_state.prefix if ctx.compilation_state is not None else "" + + with FlyteContextManager.with_context( + ctx.with_compilation_state(CompilationState(prefix=prefix, task_resolver=self)) + ) as comp_ctx: + # Construct the default input promise bindings, but then override with the provided inputs, if any + input_kwargs = construct_input_promises([k for k in self.interface.inputs.keys()]) + input_kwargs.update(kwargs) + workflow_outputs = exception_scopes.user_entry_point(self._workflow_function)(**input_kwargs) + all_nodes.extend(comp_ctx.compilation_state.nodes) + + # This little loop was added as part of the task resolver change. The task resolver interface itself is + # more or less stateless (the future-proofing get_all_tasks function notwithstanding). However the + # implementation of the TaskResolverMixin that this workflow class inherits from (ClassStorageTaskResolver) + # does store state. This loop adds Tasks that are defined within the body of the workflow to the workflow + # object itself. + for n in comp_ctx.compilation_state.nodes: + if isinstance(n.flyte_entity, PythonAutoContainerTask) and n.flyte_entity.task_resolver == self: + logger.debug(f"WF {self.name} saving task {n.flyte_entity.name}") + self.add(n.flyte_entity) + + self._validate_add_on_failure_handler(comp_ctx, comp_ctx.compilation_state.prefix + "f", input_kwargs) + + # Iterate through the workflow outputs + bindings = [] + output_names = list(self.interface.outputs.keys()) + # The reason the length 1 case is separate is because the one output might be a list. We don't want to + # iterate through the list here, instead we should let the binding creation unwrap it and make a binding + # collection/map out of it. + if len(output_names) == 1: + if isinstance(workflow_outputs, tuple): + if len(workflow_outputs) != 1: + raise AssertionError( + f"The Workflow specification indicates only one return value, received {len(workflow_outputs)}" + ) + if self.python_interface.output_tuple_name is None: + raise AssertionError( + "Outputs specification for Workflow does not define a tuple, but return value is a tuple" + ) + workflow_outputs = workflow_outputs[0] + t = self.python_interface.outputs[output_names[0]] + try: + b, _ = binding_from_python_std( + ctx, + output_names[0], + self.interface.outputs[output_names[0]].type, + workflow_outputs, + t, + ) + bindings.append(b) + except Exception as e: + raise FlyteValidationException( + f"Failed to bind output {output_names[0]} for function {self.name}: {e}" + ) from e + elif len(output_names) > 1: + if not isinstance(workflow_outputs, tuple): + raise AssertionError("The Workflow specification indicates multiple return values, received only one") + if len(output_names) != len(workflow_outputs): + raise Exception(f"Length mismatch {len(output_names)} vs {len(workflow_outputs)}") + for i, out in enumerate(output_names): + if isinstance(workflow_outputs[i], ConditionalSection): + raise AssertionError("A Conditional block (if-else) should always end with an `else_()` clause") + t = self.python_interface.outputs[out] + try: + b, _ = binding_from_python_std( + ctx, + out, + self.interface.outputs[out].type, + workflow_outputs[i], + t, + ) + bindings.append(b) + except Exception as e: + raise FlyteValidationException(f"Failed to bind output {out} for function {self.name}: {e}") from e + + # Save all the things necessary to create an WorkflowTemplate, except for the missing project and domain + self._nodes = all_nodes + self._output_bindings = bindings + + if not output_names: + return None + if len(output_names) == 1: + return bindings[0] + return tuple(bindings) + + def execute(self, **kwargs): + """ + This function is here only to try to streamline the pattern between workflows and tasks. Since tasks + call execute from dispatch_execute which is in local_execute, workflows should also call an execute inside + local_execute. This makes mocking cleaner. + """ + return exception_scopes.user_entry_point(self._workflow_function)(**kwargs) + + +@overload +def workflow( + _workflow_function: None = ..., + failure_policy: Optional[WorkflowFailurePolicy] = ..., + interruptible: bool = ..., + on_failure: Optional[Union[WorkflowBase, Task]] = ..., + docs: Optional[Documentation] = ..., +) -> Callable[[Callable[..., FuncOut]], PythonFunctionWorkflow]: + ... + + +@overload +def workflow( + _workflow_function: Callable[..., FuncOut], + failure_policy: Optional[WorkflowFailurePolicy] = ..., + interruptible: bool = ..., + on_failure: Optional[Union[WorkflowBase, Task]] = ..., + docs: Optional[Documentation] = ..., +) -> Union[PythonFunctionWorkflow, Callable[..., FuncOut]]: + ... + + +def workflow( + _workflow_function: Optional[Callable[..., Any]] = None, + failure_policy: Optional[WorkflowFailurePolicy] = None, + interruptible: bool = False, + on_failure: Optional[Union[WorkflowBase, Task]] = None, + docs: Optional[Documentation] = None, +) -> Union[Callable[[Callable[..., FuncOut]], PythonFunctionWorkflow], PythonFunctionWorkflow, Callable[..., FuncOut]]: + """ + 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. + + Unlike a task, the function body of a workflow is evaluated at serialization-time (aka compile-time). This is + because while we can determine the entire structure of a task by looking at the function's signature, workflows need + to run through the function itself because the body of the function is what expresses the workflow structure. It's + also important to note that, local execution notwithstanding, it is not evaluated again when the workflow runs on + Flyte. + That is, workflows should not call non-Flyte entities since they are only run once (again, this is with respect to + the platform, local runs notwithstanding). + + Example: + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_workflows.py + :pyobject: my_wf_example + + Again, users should keep in mind that even though the body of the function looks like regular Python, it is + actually not. When flytekit scans the workflow function, the objects being passed around between the tasks are not + your typical Python values. So even though you may have a task ``t1() -> int``, when ``a = t1()`` is called, ``a`` + will not be an integer so if you try to ``range(a)`` you'll get an error. + + Please see the :ref:`user guide ` for more usage examples. + + :param _workflow_function: This argument is implicitly passed and represents the decorated function. + :param failure_policy: Use the options in flytekit.WorkflowFailurePolicy + :param interruptible: Whether or not tasks launched from this workflow are by default interruptible + :param on_failure: Invoke this workflow or task on failure. The Workflow / task has to match the signature of + the current workflow, with an additional parameter called `error` Error + :param docs: Description entity for the workflow + """ + + def wrapper(fn: Callable[..., Any]) -> PythonFunctionWorkflow: + workflow_metadata = WorkflowMetadata(on_failure=failure_policy or WorkflowFailurePolicy.FAIL_IMMEDIATELY) + + workflow_metadata_defaults = WorkflowMetadataDefaults(interruptible) + + workflow_instance = PythonFunctionWorkflow( + fn, + metadata=workflow_metadata, + default_metadata=workflow_metadata_defaults, + docstring=Docstring(callable_=fn), + on_failure=on_failure, + docs=docs, + ) + update_wrapper(workflow_instance, fn) + return workflow_instance + + if _workflow_function is not None: + return wrapper(_workflow_function) + else: + return wrapper + + +class ReferenceWorkflow(ReferenceEntity, PythonFunctionWorkflow): # type: ignore + """ + A reference workflow is a pointer to a workflow that already exists on your Flyte installation. This + object will not initiate a network call to Admin, which is why the user is asked to provide the expected interface. + If at registration time the interface provided causes an issue with compilation, an error will be returned. + """ + + def __init__( + self, project: str, domain: str, name: str, version: str, inputs: Dict[str, Type], outputs: Dict[str, Type] + ): + super().__init__(WorkflowReference(project, domain, name, version), inputs, outputs) + + +def reference_workflow( + project: str, + domain: str, + name: str, + version: str, +) -> Callable[[Callable[..., Any]], ReferenceWorkflow]: + """ + A reference workflow is a pointer to a workflow that already exists on your Flyte installation. This + object will not initiate a network call to Admin, which is why the user is asked to provide the expected interface. + If at registration time the interface provided causes an issue with compilation, an error will be returned. + + Example: + + .. literalinclude:: ../../../tests/flytekit/unit/core/test_references.py + :pyobject: ref_wf1 + """ + + def wrapper(fn) -> ReferenceWorkflow: + interface = transform_function_to_interface(fn) + return ReferenceWorkflow(project, domain, name, version, interface.inputs, interface.outputs) + + return wrapper diff --git a/flytekit/flytekit/deck/__init__.py b/flytekit/flytekit/deck/__init__.py new file mode 100644 index 0000000000..f83049ac48 --- /dev/null +++ b/flytekit/flytekit/deck/__init__.py @@ -0,0 +1,18 @@ +""" +========== +Flyte Deck +========== + +.. currentmodule:: flytekit.deck + +Contains deck renderers provided by flytekit. + +.. autosummary:: + :toctree: generated/ + + Deck + TopFrameRenderer +""" + +from .deck import Deck +from .renderer import TopFrameRenderer diff --git a/flytekit/flytekit/deck/deck.py b/flytekit/flytekit/deck/deck.py new file mode 100644 index 0000000000..3ce9d058a4 --- /dev/null +++ b/flytekit/flytekit/deck/deck.py @@ -0,0 +1,176 @@ +import os +import typing +from typing import Optional + +from flytekit.core.context_manager import ExecutionParameters, ExecutionState, FlyteContext, FlyteContextManager +from flytekit.loggers import logger +from flytekit.tools.interactive import ipython_check + +OUTPUT_DIR_JUPYTER_PREFIX = "jupyter" +DECK_FILE_NAME = "deck.html" + + +class Deck: + """ + Deck enable users to get customizable and default visibility into their tasks. + + Deck contains a list of renderers (FrameRenderer, MarkdownRenderer) that can + generate a html file. For example, FrameRenderer can render a DataFrame as an HTML table, + MarkdownRenderer can convert Markdown string to HTML + + Flyte context saves a list of deck objects, and we use renderers in those decks to render + the data and create an HTML file when those tasks are executed + + Each task has a least three decks (input, output, default). Input/output decks are + used to render tasks' input/output data, and the default deck is used to render line plots, + scatter plots or Markdown text. In addition, users can create new decks to render + their data with custom renderers. + + .. warning:: + + This feature is in beta. + + .. code-block:: python + + iris_df = px.data.iris() + + @task() + def t1() -> str: + md_text = '#Hello Flyte##Hello Flyte###Hello Flyte' + m = MarkdownRenderer() + s = BoxRenderer("sepal_length") + deck = flytekit.Deck("demo", s.to_html(iris_df)) + deck.append(m.to_html(md_text)) + default_deck = flytekit.current_context().default_deck + default_deck.append(m.to_html(md_text)) + return md_text + + + # Use Annotated to override default renderer + @task() + def t2() -> Annotated[pd.DataFrame, TopFrameRenderer(10)]: + return iris_df + """ + + def __init__(self, name: str, html: Optional[str] = ""): + self._name = name + self._html = html + FlyteContextManager.current_context().user_space_params.decks.append(self) + + def append(self, html: str) -> "Deck": + assert isinstance(html, str) + self._html = self._html + "\n" + html + return self + + @property + def name(self) -> str: + return self._name + + @property + 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)) + + gantt_chart_html = GanttChartRenderer().to_html(df) + time_table_html = TableRenderer().to_html( + df[["Name", "WallTime", "ProcessTime"]], + header_labels=["Name", "Wall Time(s)", "Process Time(s)"], + ) + return gantt_chart_html + time_table_html + note + + +def _get_deck( + new_user_params: ExecutionParameters, ignore_jupyter: bool = False +) -> typing.Union[str, "IPython.core.display.HTML"]: # type:ignore + """ + Get flyte deck html string + 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 = get_deck_template().render(metadata=deck_map) + if not ignore_jupyter and ipython_check(): + try: + from IPython.core.display import HTML + except ImportError: + ... + return HTML(raw_html) + return raw_html + + +def _output_deck(task_name: str, new_user_params: ExecutionParameters): + ctx = FlyteContext.current_context() + local_dir = ctx.file_access.get_random_local_directory() + local_path = f"{local_dir}{os.sep}{DECK_FILE_NAME}" + try: + with open(local_path, "w", encoding="utf-8") as f: + f.write(_get_deck(new_user_params, ignore_jupyter=True)) + logger.info(f"{task_name} task creates flyte deck html to file://{local_path}") + if ctx.execution_state.mode == ExecutionState.Mode.TASK_EXECUTION: + fs = ctx.file_access.get_filesystem_for_path(new_user_params.output_metadata_prefix) + remote_path = f"{new_user_params.output_metadata_prefix}{ctx.file_access.sep(fs)}{DECK_FILE_NAME}" + kwargs: typing.Dict[str, str] = { + "ContentType": "text/html", # For s3 + "content_type": "text/html", # For gcs + } + ctx.file_access.put_data(local_path, remote_path, **kwargs) + except Exception as e: + logger.error(f"Failed to write flyte deck html with error {e}.") + + +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/flytekit/deck/html/__init__.py b/flytekit/flytekit/deck/html/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/deck/html/template.html b/flytekit/flytekit/deck/html/template.html new file mode 100644 index 0000000000..19e0256880 --- /dev/null +++ b/flytekit/flytekit/deck/html/template.html @@ -0,0 +1,131 @@ + + + + + User Content + + + + + + + + +
+ {% for key, value in metadata.items() %} +
{{ value | safe }}
+ {% endfor %} +
+ + + + diff --git a/flytekit/flytekit/deck/renderer.py b/flytekit/flytekit/deck/renderer.py new file mode 100644 index 0000000000..cfea92ec4e --- /dev/null +++ b/flytekit/flytekit/deck/renderer.py @@ -0,0 +1,50 @@ +from typing import TYPE_CHECKING, Any + +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 an object(markdown, pandas.dataframe) to HTML and return HTML as a unicode string. + Returns: An HTML document as a string. + """ + raise NotImplementedError + + +DEFAULT_MAX_ROWS = 10 +DEFAULT_MAX_COLS = 100 + + +class TopFrameRenderer: + """ + Render a DataFrame as an HTML table. + """ + + def __init__(self, max_rows: int = DEFAULT_MAX_ROWS, max_cols: int = DEFAULT_MAX_COLS): + self._max_rows = max_rows + self._max_cols = max_cols + + 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 an Arrow dataframe as an HTML table. + """ + + def to_html(self, df: "pyarrow.Table") -> str: + assert isinstance(df, pyarrow.Table) + return df.to_string() diff --git a/flytekit/flytekit/exceptions/__init__.py b/flytekit/flytekit/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/exceptions/base.py b/flytekit/flytekit/exceptions/base.py new file mode 100644 index 0000000000..2fca878abf --- /dev/null +++ b/flytekit/flytekit/exceptions/base.py @@ -0,0 +1,12 @@ +class _FlyteCodedExceptionMetaclass(type): + @property + def error_code(cls): + return cls._ERROR_CODE + + +class FlyteException(Exception, metaclass=_FlyteCodedExceptionMetaclass): + _ERROR_CODE = "UnknownFlyteException" + + +class FlyteRecoverableException(FlyteException): + _ERROR_CODE = "RecoverableFlyteException" diff --git a/flytekit/flytekit/exceptions/scopes.py b/flytekit/flytekit/exceptions/scopes.py new file mode 100644 index 0000000000..a9a33b748d --- /dev/null +++ b/flytekit/flytekit/exceptions/scopes.py @@ -0,0 +1,232 @@ +from functools import wraps as _wraps +from sys import exc_info as _exc_info +from traceback import format_tb as _format_tb + +from flytekit.exceptions import base as _base_exceptions +from flytekit.exceptions import system as _system_exceptions +from flytekit.exceptions import user as _user_exceptions +from flytekit.models.core import errors as _error_model + + +class FlyteScopedException(Exception): + def __init__(self, context, exc_type, exc_value, exc_tb, top_trim=0, bottom_trim=0, kind=None): + self._exc_type = exc_type + self._exc_value = exc_value + self._exc_tb = exc_tb + self._top_trim = top_trim + self._bottom_trim = bottom_trim + self._context = context + self._kind = kind + super(FlyteScopedException, self).__init__(str(self.value)) + + @property + def verbose_message(self): + tb = self.traceback + to_trim = self._top_trim + while to_trim > 0 and tb.tb_next is not None: + tb = tb.tb_next + + top_tb = tb + limit = 0 + while tb is not None: + limit += 1 + tb = tb.tb_next + limit = max(0, limit - self._bottom_trim) + + lines = _format_tb(top_tb, limit=limit) + lines = [line.rstrip() for line in lines] + lines = "\n".join(lines).split("\n") + traceback_str = "\n ".join([""] + lines) + + format_str = "Traceback (most recent call last):\n" "{traceback}\n" "\n" "Message:\n" "\n" " {message}" + return format_str.format(traceback=traceback_str, message=f"{self.type.__name__}: {self.value}") + + def __str__(self): + return str(self.value) + + @property + def value(self): + if isinstance(self._exc_value, FlyteScopedException): + return self._exc_value.value + return self._exc_value + + @property + def traceback(self): + if isinstance(self._exc_value, FlyteScopedException): + return self._exc_value.traceback + return self._exc_tb + + @property + def type(self): + if isinstance(self._exc_value, FlyteScopedException): + return self._exc_value.type + return self._exc_type + + @property + def error_code(self): + """ + :rtype: Text + """ + if isinstance(self._exc_value, FlyteScopedException): + return self._exc_value.error_code + + if hasattr(type(self._exc_value), "error_code"): + return type(self._exc_value).error_code + return "{}:Unknown".format(self._context) + + @property + def kind(self) -> int: + """ + :rtype: int + """ + if self._kind is not None: + # If kind is overridden, return it. + return self._kind + elif isinstance(self._exc_value, FlyteScopedException): + # Otherwise, go lower in the scope to find the kind of exception. + return self._exc_value.kind + elif isinstance(self._exc_value, _base_exceptions.FlyteRecoverableException): + # If it is an exception that is recoverable, we return it as such. + return _error_model.ContainerError.Kind.RECOVERABLE + else: + # The remaining exceptions are considered unrecoverable. + return _error_model.ContainerError.Kind.NON_RECOVERABLE + + +class FlyteScopedSystemException(FlyteScopedException): + def __init__(self, exc_type, exc_value, exc_tb, **kwargs): + super(FlyteScopedSystemException, self).__init__("SYSTEM", exc_type, exc_value, exc_tb, **kwargs) + + @property + def verbose_message(self): + """ + :rtype: Text + """ + base_msg = super(FlyteScopedSystemException, self).verbose_message + base_msg += "\n\nSYSTEM ERROR! Contact platform administrators." + return base_msg + + +class FlyteScopedUserException(FlyteScopedException): + def __init__(self, exc_type, exc_value, exc_tb, **kwargs): + super(FlyteScopedUserException, self).__init__("USER", exc_type, exc_value, exc_tb, **kwargs) + + @property + def verbose_message(self): + """ + :rtype: Text + """ + base_msg = super(FlyteScopedUserException, self).verbose_message + base_msg += "\n\nUser error." + return base_msg + + +_NULL_CONTEXT = 0 +_USER_CONTEXT = 1 +_SYSTEM_CONTEXT = 2 + +# Keep the stack with a null-context so we never have to range check when peeking back. +_CONTEXT_STACK = [_NULL_CONTEXT] + + +def _is_base_context(): + return _CONTEXT_STACK[-2] == _NULL_CONTEXT + + +def _decorator(outer_f): + """Decorate a function with signature func(wrapped, args, kwargs).""" + + @_wraps(outer_f) + def inner_decorator(inner_f): + @_wraps(inner_f) + def f(*args, **kwargs): + return outer_f(inner_f, args, kwargs) + + return f + + return inner_decorator + + +@_decorator +def system_entry_point(wrapped, args, kwargs): + """ + The reason these two (see the user one below) decorators exist is to categorize non-Flyte exceptions at arbitrary + locations. For example, while there is a separate ecosystem of Flyte-defined user and system exceptions + (see the FlyteException hierarchy), and we can easily understand and categorize those, if flytekit comes upon + a random ``ValueError`` or other non-flytekit defined error, how would we know if it was a bug in flytekit versus an + error with user code or something the user called? The purpose of these decorators is to categorize those (see + the last case in the nested try/catch below. + + Decorator for wrapping functions that enter a system context. This should decorate every method that may invoke some + user code later on down the line. This will allow us to add differentiation between what is a user error and + what is a system failure. Furthermore, we will clean the exception trace so as to make more sense to the + user -- allowing them to know if they should take action themselves or pass on to the platform owners. + We will dispatch metrics and such appropriately. + """ + try: + _CONTEXT_STACK.append(_SYSTEM_CONTEXT) + if _is_base_context(): + # If this is the first time either of this decorator, or the one below is called, then we unwrap the + # exception. The first time these decorators are used is currently in the entrypoint.py file. The scoped + # exceptions are unwrapped because at that point, we want to return the underlying error to the user. + try: + return wrapped(*args, **kwargs) + except FlyteScopedException as ex: + raise ex.value + else: + try: + return wrapped(*args, **kwargs) + except FlyteScopedException as scoped: + raise scoped + except _user_exceptions.FlyteUserException: + # Re-raise from here. + raise FlyteScopedUserException(*_exc_info()) + except Exception: + # This is why this function exists - arbitrary exceptions that we don't know what to do with are + # interpreted as system errors. + # System error, raise full stack-trace all the way up the chain. + raise FlyteScopedSystemException(*_exc_info(), kind=_error_model.ContainerError.Kind.RECOVERABLE) + finally: + _CONTEXT_STACK.pop() + + +@_decorator +def user_entry_point(wrapped, args, kwargs): + """ + See the comment for the system_entry_point above as well. + + Decorator for wrapping functions that enter into a user context. This will help us differentiate user-created + failures even when it is re-entrant into system code. + + Note: a user_entry_point can ONLY ever be called from within a @system_entry_point wrapped function, therefore, + we can always ensure we will hit a system_entry_point to correctly reformat our exceptions. Also, any exception + we create here will only be handled within our system code so we don't need to worry about leaking weird exceptions + to the user. + """ + try: + _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 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) + except FlyteScopedException as scoped: + raise scoped + except _user_exceptions.FlyteUserException: + raise FlyteScopedUserException(*_exc_info()) + except _system_exceptions.FlyteSystemException: + raise FlyteScopedSystemException(*_exc_info()) + except Exception: + # This is why this function exists - arbitrary exceptions that we don't know what to do with are + # interpreted as user exceptions. + # This will also catch FlyteUserException re-raised by the system_entry_point handler + raise FlyteScopedUserException(*_exc_info()) + finally: + _CONTEXT_STACK.pop() diff --git a/flytekit/flytekit/exceptions/system.py b/flytekit/flytekit/exceptions/system.py new file mode 100644 index 0000000000..63fe55f0b9 --- /dev/null +++ b/flytekit/flytekit/exceptions/system.py @@ -0,0 +1,43 @@ +from flytekit.exceptions import base as _base_exceptions + + +class FlyteSystemException(_base_exceptions.FlyteRecoverableException): + _ERROR_CODE = "SYSTEM:Unknown" + + +class FlyteNotImplementedException(FlyteSystemException, NotImplementedError): + _ERROR_CODE = "SYSTEM:NotImplemented" + + +class FlyteEntrypointNotLoadable(FlyteSystemException): + _ERROR_CODE = "SYSTEM:UnloadableCode" + + @classmethod + def _create_verbose_message(cls, task_module, task_name=None, additional_msg=None): + if task_name is None: + return "Entrypoint is not loadable! Could not load the module: '{task_module}'{additional_msg}".format( + task_module=task_module, + additional_msg=" due to error: {}".format(additional_msg) if additional_msg is not None else ".", + ) + else: + return ( + "Entrypoint is not loadable! Could not find the task: '{task_name}' in '{task_module}'" + "{additional_msg}".format( + task_module=task_module, + task_name=task_name, + additional_msg="." if additional_msg is None else " due to error: {}".format(additional_msg), + ) + ) + + def __init__(self, task_module, task_name=None, additional_msg=None): + super(FlyteSystemException, self).__init__( + self._create_verbose_message(task_module, task_name=task_name, additional_msg=additional_msg) + ) + + +class FlyteSystemAssertion(FlyteSystemException, AssertionError): + _ERROR_CODE = "SYSTEM:AssertionError" + + +class FlyteAgentNotFound(FlyteSystemException, AssertionError): + _ERROR_CODE = "SYSTEM:AgentNotFound" diff --git a/flytekit/flytekit/exceptions/user.py b/flytekit/flytekit/exceptions/user.py new file mode 100644 index 0000000000..1ed0954421 --- /dev/null +++ b/flytekit/flytekit/exceptions/user.py @@ -0,0 +1,99 @@ +import typing + +from flytekit.exceptions.base import FlyteException as _FlyteException +from flytekit.exceptions.base import FlyteRecoverableException as _Recoverable + + +class FlyteUserException(_FlyteException): + _ERROR_CODE = "USER:Unknown" + + +class FlyteTypeException(FlyteUserException, TypeError): + _ERROR_CODE = "USER:TypeError" + + @staticmethod + def _is_a_container(value): + return isinstance(value, list) or isinstance(value, tuple) or isinstance(value, set) + + @classmethod + def _create_verbose_message(cls, received_type, expected_type, received_value=None, additional_msg=None): + if received_value is not None: + return "Type error! Received: {} with value: {}, Expected{}: {}. {}".format( + received_type, + received_value, + " one of" if FlyteTypeException._is_a_container(expected_type) else "", + expected_type, + additional_msg or "", + ) + else: + return "Type error! Received: {}, Expected{}: {}. {}".format( + received_type, + " one of" if FlyteTypeException._is_a_container(expected_type) else "", + expected_type, + additional_msg or "", + ) + + def __init__(self, received_type, expected_type, additional_msg=None, received_value=None): + super(FlyteTypeException, self).__init__( + self._create_verbose_message( + received_type, + expected_type, + received_value=received_value, + additional_msg=additional_msg, + ) + ) + + +class FlyteValueException(FlyteUserException, ValueError): + _ERROR_CODE = "USER:ValueError" + + @classmethod + def _create_verbose_message(cls, received_value, error_message): + return "Value error! Received: {}. {}".format(received_value, error_message) + + def __init__(self, received_value, error_message): + super(FlyteValueException, self).__init__(self._create_verbose_message(received_value, error_message)) + + +class FlyteAssertion(FlyteUserException, AssertionError): + _ERROR_CODE = "USER:AssertionError" + + +class FlyteValidationException(FlyteAssertion): + _ERROR_CODE = "USER:ValidationError" + + +class FlyteDisapprovalException(FlyteAssertion): + _ERROR_CODE = "USER:ResultNotApproved" + + +class FlyteEntityAlreadyExistsException(FlyteAssertion): + _ERROR_CODE = "USER:EntityAlreadyExists" + + +class FlyteEntityNotExistException(FlyteAssertion): + _ERROR_CODE = "USER:EntityNotExist" + + +class FlyteTimeout(FlyteAssertion): + _ERROR_CODE = "USER:Timeout" + + +class FlyteRecoverableException(FlyteUserException, _Recoverable): + _ERROR_CODE = "USER:Recoverable" + + +class FlyteAuthenticationException(FlyteAssertion): + _ERROR_CODE = "USER:AuthenticationError" + + +class FlyteInvalidInputException(FlyteUserException): + _ERROR_CODE = "USER:BadInputToAPI" + + def __init__(self, request: typing.Any): + self.request = request + super().__init__() + + +class FlytePromiseAttributeResolveException(FlyteAssertion): + _ERROR_CODE = "USER:PromiseAttributeResolveError" diff --git a/flytekit/flytekit/experimental/__init__.py b/flytekit/flytekit/experimental/__init__.py new file mode 100644 index 0000000000..2780211c8f --- /dev/null +++ b/flytekit/flytekit/experimental/__init__.py @@ -0,0 +1,4 @@ +"""Experimental features of flytekit.""" + +from flytekit.core.array_node_map_task import map_task # noqa: F401 +from flytekit.experimental.eager_function import EagerException, eager diff --git a/flytekit/flytekit/experimental/eager_function.py b/flytekit/flytekit/experimental/eager_function.py new file mode 100644 index 0000000000..e0f252e312 --- /dev/null +++ b/flytekit/flytekit/experimental/eager_function.py @@ -0,0 +1,624 @@ +import asyncio +import inspect +import signal +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from functools import partial, wraps +from typing import List, Optional + +from flytekit import Deck, Secret, current_context +from flytekit.configuration import DataConfig, PlatformConfig, S3Config +from flytekit.core.base_task import PythonTask +from flytekit.core.context_manager import ExecutionState, FlyteContext, FlyteContextManager +from flytekit.core.python_function_task import PythonFunctionTask +from flytekit.core.task import task +from flytekit.core.workflow import WorkflowBase +from flytekit.loggers import logger +from flytekit.models.core.execution import WorkflowExecutionPhase +from flytekit.remote import FlyteRemote + +FLYTE_SANDBOX_INTERNAL_ENDPOINT = "flyte-sandbox-grpc.flyte:8089" +FLYTE_SANDBOX_MINIO_ENDPOINT = "http://flyte-sandbox-minio.flyte:9000" + +NODE_HTML_TEMPLATE = """ + + + + +

{entity_type}: {entity_name}

+ +

+ Execution: + {execution_name} +

+ +
+Inputs +
{inputs}
+
+ +
+Outputs +
{outputs}
+
+ +
+""" + + +class EagerException(Exception): + """Raised when a node in an eager workflow encounters an error. + + This exception should be used in an :py:func:`@eager ` workflow function to + catch exceptions that are raised by tasks or subworkflows. + + .. code-block:: python + + from flytekit import task + from flytekit.experimental import eager, EagerException + + @task + def add_one(x: int) -> int: + if x < 0: + raise ValueError("x must be positive") + return x + 1 + + @task + def double(x: int) -> int: + return x * 2 + + @eager + async def eager_workflow(x: int) -> int: + try: + out = await add_one(x=x) + except EagerException: + # The ValueError error is caught + # and raised as an EagerException + raise + return await double(x=out) + """ + + +class AsyncEntity: + """A wrapper around a Flyte entity (task, workflow, launch plan) that allows it to be executed asynchronously.""" + + def __init__( + self, + entity, + remote: Optional[FlyteRemote], + ctx: FlyteContext, + async_stack: "AsyncStack", + timeout: Optional[timedelta] = None, + poll_interval: Optional[timedelta] = None, + local_entrypoint: bool = False, + ): + self.entity = entity + self.ctx = ctx + self.async_stack = async_stack + self.execution_state = self.ctx.execution_state.mode + self.remote = remote + self.local_entrypoint = local_entrypoint + if self.remote is not None: + logger.debug(f"Using remote config: {self.remote.config}") + else: + logger.debug("Not using remote, executing locally") + self._timeout = timeout + self._poll_interval = poll_interval + self._execution = None + + async def __call__(self, **kwargs): + logger.debug(f"Calling {self.entity}: {self.entity.name}") + + # ensure async context is provided + if "async_ctx" in kwargs: + kwargs.pop("async_ctx") + + if getattr(self.entity, "execution_mode", None) == PythonFunctionTask.ExecutionBehavior.DYNAMIC: + raise EagerException( + "Eager workflows currently do not work with dynamic workflows. " + "If you need to use a subworkflow, use a static @workflow or nested @eager workflow." + ) + + if not self.local_entrypoint and self.ctx.execution_state.is_local_execution(): + # If running as a local workflow execution, just execute the python function + try: + if isinstance(self.entity, WorkflowBase): + out = self.entity._workflow_function(**kwargs) + if inspect.iscoroutine(out): + # need to handle invocation of AsyncEntity tasks within the workflow + out = await out + return out + elif isinstance(self.entity, PythonTask): + # invoke the task-decorated entity + out = self.entity(**kwargs) + if inspect.iscoroutine(out): + out = await out + return out + else: + raise ValueError(f"Entity type {type(self.entity)} not supported for local execution") + except Exception as exc: + raise EagerException( + f"Error executing {type(self.entity)} {self.entity.name} with {type(exc)}: {exc}" + ) from exc + + # this is a hack to handle the case when the task.name doesn't contain the fully + # qualified module name + entity_name = ( + f"{self.entity._instantiated_in}.{self.entity.name}" + if self.entity._instantiated_in not in self.entity.name + else self.entity.name + ) + + if isinstance(self.entity, WorkflowBase): + remote_entity = self.remote.fetch_workflow(name=entity_name) + elif isinstance(self.entity, PythonTask): + remote_entity = self.remote.fetch_task(name=entity_name) + else: + raise ValueError(f"Entity type {type(self.entity)} not supported for local execution") + + execution = self.remote.execute(remote_entity, inputs=kwargs, type_hints=self.entity.python_interface.inputs) + self._execution = execution + + url = self.remote.generate_console_url(execution) + msg = f"Running flyte {type(self.entity)} {entity_name} on remote cluster: {url}" + if self.local_entrypoint: + logger.info(msg) + else: + logger.debug(msg) + + node = AsyncNode(self, entity_name, execution, url) + self.async_stack.set_node(node) + + poll_interval = self._poll_interval or timedelta(seconds=30) + time_to_give_up = datetime.max if self._timeout is None else datetime.utcnow() + self._timeout + + while datetime.utcnow() < time_to_give_up: + execution = self.remote.sync(execution) + if execution.closure.phase in {WorkflowExecutionPhase.FAILED}: + raise EagerException(f"Error executing {self.entity.name} with error: {execution.closure.error}") + elif execution.is_done: + break + await asyncio.sleep(poll_interval.total_seconds()) + + outputs = {} + for key, type_ in self.entity.python_interface.outputs.items(): + outputs[key] = execution.outputs.get(key, as_type=type_) + + if len(outputs) == 1: + out, *_ = outputs.values() + return out + return outputs + + async def terminate(self): + execution = self.remote.sync(self._execution) + logger.debug(f"Cleaning up execution: {execution}") + if not execution.is_done: + self.remote.terminate( + execution, + f"Execution terminated by eager workflow execution {self.async_stack.parent_execution_id}.", + ) + + poll_interval = self._poll_interval or timedelta(seconds=6) + time_to_give_up = datetime.max if self._timeout is None else datetime.utcnow() + self._timeout + + while datetime.utcnow() < time_to_give_up: + execution = self.remote.sync(execution) + if execution.is_done: + break + await asyncio.sleep(poll_interval.total_seconds()) + + return True + + +class AsyncNode: + """A node in the async callstack.""" + + def __init__(self, async_entity, entity_name, execution=None, url=None): + self.entity_name = entity_name + self.async_entity = async_entity + self.execution = execution + self._url = url + + @property + def url(self) -> str: + # make sure that internal flyte sandbox endpoint is replaced with localhost endpoint when rendering the urls + # for flyte decks + endpoint_root = FLYTE_SANDBOX_INTERNAL_ENDPOINT.replace("http://", "") + if endpoint_root in self._url: + return self._url.replace(endpoint_root, "localhost:30080") + return self._url + + @property + def entity_type(self) -> str: + if ( + isinstance(self.async_entity.entity, PythonTask) + and getattr(self.async_entity.entity, "execution_mode", None) == PythonFunctionTask.ExecutionBehavior.EAGER + ): + return "Eager Workflow" + elif isinstance(self.async_entity.entity, PythonTask): + return "Task" + elif isinstance(self.async_entity.entity, WorkflowBase): + return "Workflow" + return str(type(self.async_entity.entity)) + + def __repr__(self): + ex_id = self.execution.id + execution_id = None if self.execution is None else f"{ex_id.project}:{ex_id.domain}:{ex_id.name}" + return ( + "" + + @property + def call_stack(self) -> List[AsyncNode]: + return self._call_stack + + def set_node(self, node: AsyncNode): + self._call_stack.append(node) + + +async def render_deck(async_stack): + """Render the callstack as a deck presentation to be shown after eager workflow execution.""" + + def get_io(dict_like): + try: + return {k: dict_like.get(k) for k in dict_like} + except Exception: + return dict_like + + output = "

Nodes


" + for node in async_stack.call_stack: + node_inputs = get_io(node.execution.inputs) + if node.execution.closure.phase in {WorkflowExecutionPhase.FAILED}: + node_outputs = None + else: + node_outputs = get_io(node.execution.outputs) + + output = f"{output}\n" + NODE_HTML_TEMPLATE.format( + entity_type=node.entity_type, + entity_name=node.entity_name, + execution_name=node.execution.id.name, + url=node.url, + inputs=node_inputs, + outputs=node_outputs, + ) + + Deck("eager workflow", output) + + +@asynccontextmanager +async def eager_context( + fn, + remote: Optional[FlyteRemote], + ctx: FlyteContext, + async_stack: AsyncStack, + timeout: Optional[timedelta] = None, + poll_interval: Optional[timedelta] = None, + local_entrypoint: bool = False, +): + """This context manager overrides all tasks in the global namespace with async versions.""" + + _original_cache = {} + + # override tasks with async version + for k, v in fn.__globals__.items(): + if isinstance(v, (PythonTask, WorkflowBase)): + _original_cache[k] = v + fn.__globals__[k] = AsyncEntity(v, remote, ctx, async_stack, timeout, poll_interval, local_entrypoint) + + try: + yield + finally: + # restore old tasks + for k, v in _original_cache.items(): + fn.__globals__[k] = v + + +async def node_cleanup_async(sig, loop, async_stack: AsyncStack): + """Clean up subtasks when eager workflow parent is done. + + This applies either if the eager workflow completes successfully, fails, or is cancelled by the user. + """ + logger.debug(f"Cleaning up async nodes on signal: {sig}") + terminations = [] + for node in async_stack.call_stack: + terminations.append(node.async_entity.terminate()) + results = await asyncio.gather(*terminations) + logger.debug(f"Successfully terminated subtasks {results}") + + +def node_cleanup(sig, frame, loop, async_stack: AsyncStack): + """Clean up subtasks when eager workflow parent is done. + + This applies either if the eager workflow completes successfully, fails, or is cancelled by the user. + """ + logger.debug(f"Cleaning up async nodes on signal: {sig}") + terminations = [] + for node in async_stack.call_stack: + terminations.append(node.async_entity.terminate()) + results = asyncio.gather(*terminations) + results = asyncio.run(results) + logger.debug(f"Successfully terminated subtasks {results}") + loop.close() + + +def eager( + _fn=None, + *, + remote: Optional[FlyteRemote] = None, + client_secret_group: Optional[str] = None, + client_secret_key: Optional[str] = None, + timeout: Optional[timedelta] = None, + poll_interval: Optional[timedelta] = None, + local_entrypoint: bool = False, + **kwargs, +): + """Eager workflow decorator. + + :param remote: A :py:class:`~flytekit.remote.FlyteRemote` object to use for executing Flyte entities. + :param client_secret_group: The client secret group to use for this workflow. + :param client_secret_key: The client secret key to use for this workflow. + :param timeout: The timeout duration specifying how long to wait for a task/workflow execution within the eager + workflow to complete or terminate. By default, the eager workflow will wait indefinitely until complete. + :param poll_interval: The poll interval for checking if a task/workflow execution within the eager workflow has + finished. If not specified, the default poll interval is 6 seconds. + :param local_entrypoint: If True, the eager workflow will can be executed locally but use the provided + :py:func:`~flytekit.remote.FlyteRemote` object to create task/workflow executions. This is useful for local + testing against a remote Flyte cluster. + :param kwargs: keyword-arguments forwarded to :py:func:`~flytekit.task`. + + This type of workflow will execute all flyte entities within it eagerly, meaning that all python constructs can be + used inside of an ``@eager``-decorated function. This is because eager workflows use a + :py:class:`~flytekit.remote.remote.FlyteRemote` object to kick off executions when a flyte entity needs to produce a + value. + + For example: + + .. code-block:: python + + from flytekit import task + from flytekit.experimental import eager + + @task + def add_one(x: int) -> int: + return x + 1 + + @task + def double(x: int) -> int: + return x * 2 + + @eager + async def eager_workflow(x: int) -> int: + out = await add_one(x=x) + return await double(x=out) + + # run locally with asyncio + if __name__ == "__main__": + import asyncio + + result = asyncio.run(eager_workflow(x=1)) + print(f"Result: {result}") # "Result: 4" + + Unlike :py:func:`dynamic workflows `, eager workflows are not compiled into a workflow spec, but + uses python's `async `__ capabilities to execute flyte entities. + + .. note:: + + Eager workflows only support `@task`, `@workflow`, and `@eager` entities. Dynamic workflows and launchplans are + currently not supported. + + Note that for the ``@eager`` function is an ``async`` function. Under the hood, tasks and workflows called inside + an ``@eager`` workflow are executed asynchronously. This means that task and workflow calls will return an awaitable, + which need to be awaited. + + .. important:: + + A ``client_secret_group`` and ``client_secret_key`` is needed for authenticating via + :py:class:`~flytekit.remote.remote.FlyteRemote` using the ``client_credentials`` authentication, which is + configured via :py:class:`~flytekit.configuration.PlatformConfig`. + + .. code-block:: python + + from flytekit.remote import FlyteRemote + from flytekit.configuration import Config + + @eager( + remote=FlyteRemote(config=Config.auto(config_file="config.yaml")), + client_secret_group="my_client_secret_group", + client_secret_key="my_client_secret_key", + ) + async def eager_workflow(x: int) -> int: + out = await add_one(x) + return await double(one) + + Where ``config.yaml`` contains is a flytectl-compatible config file. + For more details, see `here `__. + + When using a sandbox cluster started with ``flytectl demo start``, however, the ``client_secret_group`` + and ``client_secret_key`` are not needed, : + + .. code-block:: python + + @eager(remote=FlyteRemote(config=Config.for_sandbox())) + async def eager_workflow(x: int) -> int: + ... + + .. important:: + + When using ``local_entrypoint=True`` you also need to specify the ``remote`` argument. In this case, the eager + workflow runtime will be local, but all task/subworkflow invocations will occur on the specified Flyte cluster. + This argument is primarily used for testing and debugging eager workflow logic locally. + + """ + + if _fn is None: + return partial( + eager, + remote=remote, + client_secret_group=client_secret_group, + client_secret_key=client_secret_key, + local_entrypoint=local_entrypoint, + **kwargs, + ) + + if local_entrypoint and remote is None: + raise ValueError("Must specify remote argument if local_entrypoint is True") + + @wraps(_fn) + async def wrapper(*args, **kws): + # grab the "async_ctx" argument injected by PythonFunctionTask.execute + logger.debug("Starting") + _remote = remote + + # locally executed nested eager workflows won't have async_ctx injected into the **kws input + ctx = kws.pop("async_ctx", None) + task_id, execution_id = None, None + if ctx: + exec_params = ctx.user_space_params + task_id = exec_params.task_id + execution_id = exec_params.execution_id + + async_stack = AsyncStack(task_id, execution_id) + _remote = _prepare_remote(_remote, ctx, client_secret_group, client_secret_key, local_entrypoint) + + # make sure sub-nodes as cleaned up on termination signal + loop = asyncio.get_event_loop() + node_cleanup_partial = partial(node_cleanup_async, async_stack=async_stack) + cleanup_fn = partial(asyncio.ensure_future, node_cleanup_partial(signal.SIGTERM, loop)) + signal.signal(signal.SIGTERM, partial(node_cleanup, loop=loop, async_stack=async_stack)) + + async with eager_context(_fn, _remote, ctx, async_stack, timeout, poll_interval, local_entrypoint): + try: + if _remote is not None: + with _remote.remote_context(): + out = await _fn(*args, **kws) + else: + out = await _fn(*args, **kws) + # need to await for _fn to complete, then invoke the deck + await render_deck(async_stack) + return out + finally: + # in case the cleanup function hasn't been called yet, call it at the end of the eager workflow + await cleanup_fn() + + secret_requests = kwargs.pop("secret_requests", None) or [] + if client_secret_group is not None and client_secret_key is not None: + secret_requests.append(Secret(group=client_secret_group, key=client_secret_key)) + + return task( + wrapper, + secret_requests=secret_requests, + enable_deck=True, + execution_mode=PythonFunctionTask.ExecutionBehavior.EAGER, + **kwargs, + ) + + +def _prepare_remote( + remote: Optional[FlyteRemote], + ctx: FlyteContext, + client_secret_group: Optional[str] = None, + client_secret_key: Optional[str] = None, + local_entrypoint: bool = False, +) -> Optional[FlyteRemote]: + """Prepare FlyteRemote object for accessing Flyte cluster in a task running on the same cluster.""" + + is_local_execution_mode = ctx.execution_state.mode in { + ExecutionState.Mode.LOCAL_TASK_EXECUTION, + ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION, + } + + if remote is not None and local_entrypoint and is_local_execution_mode: + # when running eager workflows as a local entrypoint, we don't have to modify the remote object + # because we can assume that the user is running this from their local machine and can do browser-based + # authentication. + logger.info("Running eager workflow as local entrypoint") + return remote + + if remote is None or is_local_execution_mode: + # if running the "eager workflow" (which is actually task) locally, run the task as a function, + # which doesn't need a remote object + return None + + # Handle the case where this the task is running in a Flyte cluster and needs to access the cluster itself + # via FlyteRemote. + if remote.config.platform.endpoint.startswith("localhost"): + # replace sandbox endpoints with internal dns, since localhost won't exist within the Flyte cluster + return _internal_demo_remote(remote) + return _internal_remote(remote, client_secret_group, client_secret_key) + + +def _internal_demo_remote(remote: FlyteRemote) -> FlyteRemote: + """Derives a FlyteRemote object from a sandbox yaml configuration, modifying parts to make it work internally.""" + # replace sandbox endpoints with internal dns, since localhost won't exist within the Flyte cluster + return FlyteRemote( + config=remote.config.with_params( + platform=PlatformConfig( + endpoint=FLYTE_SANDBOX_INTERNAL_ENDPOINT, + insecure=True, + auth_mode="Pkce", + client_id=remote.config.platform.client_id, + ), + data_config=DataConfig( + s3=S3Config( + endpoint=FLYTE_SANDBOX_MINIO_ENDPOINT, + access_key_id=remote.config.data_config.s3.access_key_id, + secret_access_key=remote.config.data_config.s3.secret_access_key, + ), + ), + ), + default_domain=remote.default_domain, + default_project=remote.default_project, + ) + + +def _internal_remote( + remote: FlyteRemote, + client_secret_group: str, + client_secret_key: str, +) -> FlyteRemote: + """Derives a FlyteRemote object from a yaml configuration file, modifying parts to make it work internally.""" + assert client_secret_group is not None, "secret_group must be defined when using a remote cluster" + assert client_secret_key is not None, "secret_key must be defined a remote cluster" + secrets_manager = current_context().secrets + client_secret = secrets_manager.get(client_secret_group, client_secret_key) + # get the raw output prefix from the context that's set from the pyflyte-execute entrypoint + # (see flytekit/bin/entrypoint.py) + ctx = FlyteContextManager.current_context() + return FlyteRemote( + config=remote.config.with_params( + platform=PlatformConfig( + endpoint=remote.config.platform.endpoint, + insecure=remote.config.platform.insecure, + auth_mode="client_credentials", + client_id=remote.config.platform.client_id, + client_credentials_secret=remote.config.platform.client_credentials_secret or client_secret, + ), + ), + default_domain=remote.default_domain, + default_project=remote.default_project, + data_upload_location=ctx.file_access.raw_output_prefix, + ) diff --git a/flytekit/flytekit/extend/__init__.py b/flytekit/flytekit/extend/__init__.py new file mode 100644 index 0000000000..07e92e4c24 --- /dev/null +++ b/flytekit/flytekit/extend/__init__.py @@ -0,0 +1,44 @@ +""" +================== +Extending Flytekit +================== + +.. currentmodule:: flytekit.extend + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :toctree: generated/ + + get_serializable + context_manager + IgnoreOutputs + ExecutionState + Image + ImageConfig + Interface + Promise + TaskPlugins + DictTransformer + T + TypeEngine + TypeTransformer + PythonCustomizedContainerTask + ExecutableTemplateShimTask + ShimTaskExecutor +""" + +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.core import context_manager +from flytekit.core.base_sql_task import SQLTask +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 FileAccessProvider +from flytekit.core.interface import Interface +from flytekit.core.promise import Promise +from flytekit.core.python_customized_container_task import PythonCustomizedContainerTask +from flytekit.core.shim_task import ExecutableTemplateShimTask, ShimTaskExecutor +from flytekit.core.task import TaskPlugins +from flytekit.core.type_engine import DictTransformer, T, TypeEngine, TypeTransformer +from flytekit.tools.translator import get_serializable diff --git a/flytekit/flytekit/extend/backend/__init__.py b/flytekit/flytekit/extend/backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/extend/backend/agent_service.py b/flytekit/flytekit/extend/backend/agent_service.py new file mode 100644 index 0000000000..2d4246c6c1 --- /dev/null +++ b/flytekit/flytekit/extend/backend/agent_service.py @@ -0,0 +1,125 @@ +import typing + +import grpc +from flyteidl.admin.agent_pb2 import ( + CreateTaskRequest, + CreateTaskResponse, + DeleteTaskRequest, + DeleteTaskResponse, + GetAgentRequest, + GetAgentResponse, + GetTaskRequest, + GetTaskResponse, + ListAgentsRequest, + ListAgentsResponse, +) +from flyteidl.service.agent_pb2_grpc import AgentMetadataServiceServicer, AsyncAgentServiceServicer +from prometheus_client import Counter, Summary + +from flytekit import logger +from flytekit.exceptions.system import FlyteAgentNotFound +from flytekit.extend.backend.base_agent import AgentRegistry, mirror_async_methods +from flytekit.models.literals import LiteralMap +from flytekit.models.task import TaskTemplate + +metric_prefix = "flyte_agent_" +create_operation = "create" +get_operation = "get" +delete_operation = "delete" + +# Follow the naming convention. https://prometheus.io/docs/practices/naming/ +request_success_count = Counter( + f"{metric_prefix}requests_success_total", + "Total number of successful requests", + ["task_type", "operation"], +) +request_failure_count = Counter( + f"{metric_prefix}requests_failure_total", + "Total number of failed requests", + ["task_type", "operation", "error_code"], +) +request_latency = Summary( + f"{metric_prefix}request_latency_seconds", + "Time spent processing agent request", + ["task_type", "operation"], +) +input_literal_size = Summary(f"{metric_prefix}input_literal_bytes", "Size of input literal", ["task_type"]) + + +def agent_exception_handler(func: typing.Callable): + async def wrapper( + self, + request: typing.Union[CreateTaskRequest, GetTaskRequest, DeleteTaskRequest], + context: grpc.ServicerContext, + *args, + **kwargs, + ): + if isinstance(request, CreateTaskRequest): + task_type = request.template.type + operation = create_operation + if request.inputs: + input_literal_size.labels(task_type=task_type).observe(request.inputs.ByteSize()) + elif isinstance(request, GetTaskRequest): + task_type = request.task_type + operation = get_operation + elif isinstance(request, DeleteTaskRequest): + task_type = request.task_type + operation = delete_operation + else: + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + return + + try: + with request_latency.labels(task_type=task_type, operation=operation).time(): + res = await func(self, request, context, *args, **kwargs) + request_success_count.labels(task_type=task_type, operation=operation).inc() + return res + except FlyteAgentNotFound: + error_message = f"Cannot find agent for task type: {task_type}." + logger.error(error_message) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(error_message) + request_failure_count.labels(task_type=task_type, operation=operation, error_code="404").inc() + except Exception as e: + error_message = f"failed to {operation} {task_type} task with error {e}." + logger.error(error_message) + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(error_message) + request_failure_count.labels(task_type=task_type, operation=operation, error_code="500").inc() + + return wrapper + + +class AsyncAgentService(AsyncAgentServiceServicer): + @agent_exception_handler + async def CreateTask(self, request: CreateTaskRequest, context: grpc.ServicerContext) -> CreateTaskResponse: + tmp = TaskTemplate.from_flyte_idl(request.template) + inputs = LiteralMap.from_flyte_idl(request.inputs) if request.inputs else None + agent = AgentRegistry.get_agent(tmp.type) + + logger.info(f"{tmp.type} agent start creating the job") + return await mirror_async_methods( + agent.create, output_prefix=request.output_prefix, task_template=tmp, inputs=inputs + ) + + @agent_exception_handler + async def GetTask(self, request: GetTaskRequest, context: grpc.ServicerContext) -> GetTaskResponse: + agent = AgentRegistry.get_agent(request.task_type) + logger.info(f"{agent.task_type} agent start checking the status of the job") + return await mirror_async_methods(agent.get, resource_meta=request.resource_meta) + + @agent_exception_handler + async def DeleteTask(self, request: DeleteTaskRequest, context: grpc.ServicerContext) -> DeleteTaskResponse: + agent = AgentRegistry.get_agent(request.task_type) + logger.info(f"{agent.task_type} agent start deleting the job") + return await mirror_async_methods(agent.delete, resource_meta=request.resource_meta) + + +class AgentMetadataService(AgentMetadataServiceServicer): + async def GetAgent(self, request: GetAgentRequest, context: grpc.ServicerContext) -> GetAgentResponse: + return GetAgentResponse(agent=AgentRegistry._METADATA[request.name]) + + async def ListAgents(self, request: ListAgentsRequest, context: grpc.ServicerContext) -> ListAgentsResponse: + agents = [agent for agent in AgentRegistry._METADATA.values()] + return ListAgentsResponse(agents=agents) diff --git a/flytekit/flytekit/extend/backend/base_agent.py b/flytekit/flytekit/extend/backend/base_agent.py new file mode 100644 index 0000000000..5a6e5cd3bf --- /dev/null +++ b/flytekit/flytekit/extend/backend/base_agent.py @@ -0,0 +1,273 @@ +import asyncio +import inspect +import signal +import sys +import time +import typing +from abc import ABC +from collections import OrderedDict +from functools import partial +from types import FrameType, coroutine + +from flyteidl.admin.agent_pb2 import ( + Agent, + CreateTaskResponse, + DeleteTaskResponse, + GetTaskResponse, +) +from flyteidl.core import literals_pb2 +from flyteidl.core.execution_pb2 import TaskExecution +from flyteidl.core.tasks_pb2 import TaskTemplate +from rich.progress import Progress + +import flytekit +from flytekit import FlyteContext, PythonFunctionTask, logger +from flytekit.configuration import ImageConfig, SerializationSettings +from flytekit.core import utils +from flytekit.core.base_task import PythonTask +from flytekit.core.type_engine import TypeEngine +from flytekit.exceptions.system import FlyteAgentNotFound +from flytekit.exceptions.user import FlyteUserException +from flytekit.models.literals import LiteralMap + + +class AgentBase(ABC): + """ + This is the base class for all agents. It defines the interface that all agents must implement. + The agent service will be run either locally or in a pod, and will be responsible for + invoking agents. The propeller will communicate with the agent service + to create tasks, get the status of tasks, and delete tasks. + + All the agents should be registered in the AgentRegistry. Agent Service + will look up the agent based on the task type. Every task type can only have one agent. + """ + + name = "Base Agent" + + def __init__(self, task_type: str, **kwargs): + self._task_type = task_type + + @property + def task_type(self) -> str: + """ + task_type is the name of the task type that this agent supports. + """ + return self._task_type + + def create( + self, + output_prefix: str, + task_template: TaskTemplate, + inputs: typing.Optional[LiteralMap] = None, + **kwargs, + ) -> CreateTaskResponse: + """ + Return a Unique ID for the task that was created. It should return error code if the task creation failed. + """ + raise NotImplementedError + + def get(self, resource_meta: bytes, **kwargs) -> GetTaskResponse: + """ + 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. + """ + raise NotImplementedError + + def delete(self, resource_meta: bytes, **kwargs) -> DeleteTaskResponse: + """ + Delete the task. This call should be idempotent. + """ + raise NotImplementedError + + +class AgentRegistry(object): + """ + This is the registry for all agents. + The agent service will look up the agent registry based on the task type. + The agent metadata service will look up the agent metadata based on the agent name. + """ + + _REGISTRY: typing.Dict[str, AgentBase] = {} + _METADATA: typing.Dict[str, Agent] = {} + + @staticmethod + def register(agent: AgentBase): + if agent.task_type in AgentRegistry._REGISTRY: + raise ValueError(f"Duplicate agent for task type {agent.task_type}") + AgentRegistry._REGISTRY[agent.task_type] = agent + + if agent.name in AgentRegistry._METADATA: + agent_metadata = AgentRegistry._METADATA[agent.name] + agent_metadata.supported_task_types.append(agent.task_type) + else: + agent_metadata = Agent(name=agent.name, supported_task_types=[agent.task_type]) + AgentRegistry._METADATA[agent.name] = agent_metadata + + logger.info(f"Registering an agent for task type: {agent.task_type}, name: {agent.name}") + + @staticmethod + def get_agent(task_type: str) -> AgentBase: + if task_type not in AgentRegistry._REGISTRY: + raise FlyteAgentNotFound(f"Cannot find agent for task type: {task_type}.") + return AgentRegistry._REGISTRY[task_type] + + @staticmethod + def get_agent_metadata(name: str) -> Agent: + if name not in AgentRegistry._METADATA: + raise FlyteAgentNotFound(f"Cannot find agent for name: {name}.") + return AgentRegistry._METADATA[name] + + +def mirror_async_methods(func: typing.Callable, **kwargs) -> typing.Coroutine: + if inspect.iscoroutinefunction(func): + return func(**kwargs) + args = [v for _, v in kwargs.items()] + return asyncio.get_running_loop().run_in_executor(None, func, *args) + + +def convert_to_flyte_phase(state: str) -> TaskExecution.Phase: + """ + Convert the state from the agent to the phase in flyte. + """ + state = state.lower() + # timedout is the state of Databricks job. https://docs.databricks.com/en/workflows/jobs/jobs-2.0-api.html#runresultstate + if state in ["failed", "timeout", "timedout", "canceled"]: + return TaskExecution.FAILED + elif state in ["done", "succeeded", "success"]: + return TaskExecution.SUCCEEDED + elif state in ["running"]: + return TaskExecution.RUNNING + raise ValueError(f"Unrecognized state: {state}") + + +def is_terminal_phase(phase: TaskExecution.Phase) -> bool: + """ + Return true if the phase is terminal. + """ + return phase in [TaskExecution.SUCCEEDED, TaskExecution.ABORTED, TaskExecution.FAILED] + + +def get_agent_secret(secret_key: str) -> str: + return flytekit.current_context().secrets.get(secret_key) + + +class AsyncAgentExecutorMixin: + """ + This mixin class is used to run the agent task locally, and it's only used for local execution. + Task should inherit from this class if the task can be run in the agent. + It can handle asynchronous tasks and synchronous tasks. + Asynchronous tasks are tasks that take a long time to complete, such as running a query. + Synchronous tasks run quickly and can return their results instantly. Sending a prompt to ChatGPT and getting a response, or retrieving some metadata from a backend system. + """ + + _clean_up_task: coroutine = None + _agent: AgentBase = None + _entity: PythonTask = None + + def execute(self, **kwargs) -> typing.Any: + ctx = FlyteContext.current_context() + ss = ctx.serialization_settings or SerializationSettings(ImageConfig()) + output_prefix = ctx.file_access.get_random_remote_directory() + + from flytekit.tools.translator import get_serializable + + self._entity = typing.cast(PythonTask, self) + task_template = get_serializable(OrderedDict(), ss, self._entity).template + self._agent = AgentRegistry.get_agent(task_template.type) + + res = asyncio.run(self._create(task_template, output_prefix, kwargs)) + + # If the task is synchronous, the agent will return the output from the resource literals. + if res.HasField("resource"): + if res.resource.phase != TaskExecution.SUCCEEDED: + raise FlyteUserException(f"Failed to run the task {self._entity.name}") + return LiteralMap.from_flyte_idl(res.resource.outputs) + + res = asyncio.run(self._get(resource_meta=res.resource_meta)) + + if res.resource.phase != TaskExecution.SUCCEEDED: + raise FlyteUserException(f"Failed to run the task {self._entity.name}") + + # Read the literals from a remote file, if agent doesn't return the output literals. + if task_template.interface.outputs and len(res.resource.outputs.literals) == 0: + local_outputs_file = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(f"{output_prefix}/output/outputs.pb", local_outputs_file) + output_proto = utils.load_proto_from_file(literals_pb2.LiteralMap, local_outputs_file) + return LiteralMap.from_flyte_idl(output_proto) + + return LiteralMap.from_flyte_idl(res.resource.outputs) + + async def _create( + self, task_template: TaskTemplate, output_prefix: str, inputs: typing.Dict[str, typing.Any] = None + ) -> CreateTaskResponse: + ctx = FlyteContext.current_context() + + # Convert python inputs to literals + literals = inputs or {} + for k, v in inputs.items(): + literals[k] = TypeEngine.to_literal(ctx, v, type(v), self._entity.interface.inputs[k].type) + literal_map = LiteralMap(literals) + + if isinstance(self, PythonFunctionTask): + # Write the inputs to a remote file, so that the remote task can read the inputs from this file. + path = ctx.file_access.get_random_local_path() + utils.write_proto_to_file(literal_map.to_flyte_idl(), path) + ctx.file_access.put_data(path, f"{output_prefix}/inputs.pb") + task_template = render_task_template(task_template, output_prefix) + + res = await mirror_async_methods( + self._agent.create, + output_prefix=output_prefix, + task_template=task_template, + inputs=literal_map, + ) + + signal.signal(signal.SIGINT, partial(self.signal_handler, res.resource_meta)) # type: ignore + return res + + async def _get(self, resource_meta: bytes) -> GetTaskResponse: + phase = TaskExecution.RUNNING + + progress = Progress(transient=True) + task = progress.add_task(f"[cyan]Running Task {self._entity.name}...", total=None) + task_phase = progress.add_task("[cyan]Task phase: RUNNING, Phase message: ", total=None, visible=False) + task_log_links = progress.add_task("[cyan]Log Links: ", total=None, visible=False) + with progress: + while not is_terminal_phase(phase): + progress.start_task(task) + time.sleep(1) + res = await mirror_async_methods(self._agent.get, resource_meta=resource_meta) + if self._clean_up_task: + await self._clean_up_task + sys.exit(1) + + phase = res.resource.phase + progress.update( + task_phase, + description=f"[cyan]Task phase: {TaskExecution.Phase.Name(phase)}, Phase message: {res.resource.message}", + visible=True, + ) + log_links = "" + for link in res.log_links: + log_links += f"{link.name}: {link.uri}\n" + if log_links: + progress.update(task_log_links, description=f"[cyan]{log_links}", visible=True) + + return res + + def signal_handler(self, resource_meta: bytes, signum: int, frame: FrameType) -> typing.Any: + if self._clean_up_task is None: + co = mirror_async_methods(self._agent.delete, resource_meta=resource_meta) + self._clean_up_task = asyncio.create_task(co) + + +def render_task_template(tt: TaskTemplate, file_prefix: str) -> TaskTemplate: + args = tt.container.args + for i in range(len(args)): + tt.container.args[i] = args[i].replace("{{.input}}", f"{file_prefix}/inputs.pb") + tt.container.args[i] = args[i].replace("{{.outputPrefix}}", f"{file_prefix}/output") + tt.container.args[i] = args[i].replace("{{.rawOutputDataPrefix}}", f"{file_prefix}/raw_output") + tt.container.args[i] = args[i].replace("{{.checkpointOutputPrefix}}", f"{file_prefix}/checkpoint_output") + tt.container.args[i] = args[i].replace("{{.prevCheckpointPrefix}}", f"{file_prefix}/prev_checkpoint") + return tt diff --git a/flytekit/flytekit/extras/__init__.py b/flytekit/flytekit/extras/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/extras/accelerators.py b/flytekit/flytekit/extras/accelerators.py new file mode 100644 index 0000000000..6f3fac9ffd --- /dev/null +++ b/flytekit/flytekit/extras/accelerators.py @@ -0,0 +1,278 @@ +""" +Specifying Accelerators +========================== + +.. tags:: MachineLearning, Advanced, Hardware + +Flyte allows you to specify `gpu` resources for a given task. However, in some cases, you may want to use a different +accelerator type, such as TPU, specific variations of GPUs, or fractional GPUs. You can configure the Flyte backend to +use your preferred accelerators, and those who write workflow code can import the `flytekit.extras.accelerators` module +to specify an accelerator in the task decorator. + + +If you want to use a specific GPU device, you can pass the device name directly to the task decorator, e.g.: + +.. code-block:: + + @task( + limits=Resources(gpu="1"), + accelerator=GPUAccelerator("nvidia-tesla-v100"), + ) + def my_task() -> None: + ... + + +Base Classes +------------ +These classes can be used to create custom accelerator type constants. For example, you can create a TPU accelerator. + + + +.. currentmodule:: flytekit.extras.accelerators + +.. autosummary:: + + BaseAccelerator + GPUAccelerator + MultiInstanceGPUAccelerator + +But, often, you may want to use a well known accelerator type, and to simplify this, flytekit provides a set of +predefined accelerator constants, as described in the next section. + + +Predefined Accelerator Constants +-------------------------------- + +The `flytekit.extras.accelerators` module provides some constants for known accelerators, listed below, but this is not +a complete list. If you know the name of the accelerator, you can pass the string name to the task decorator directly. + +If using the constants, you can import them directly from the module, e.g.: + +.. code-block:: + + from flytekit.extras.accelerators import T4 + + @task( + limits=Resources(gpu="1"), + accelerator=T4, + ) + def my_task() -> None: + ... + +if you want to use a fractional GPU, you can use the ``partitioned`` method on the accelerator constant, e.g.: + +.. code-block:: + + from flytekit.extras.accelerators import A100 + + @task( + limits=Resources(gpu="1"), + accelerator=A100.partition_2g_10gb, + ) + def my_task() -> None: + ... + +.. currentmodule:: flytekit.extras.accelerators + +.. autosummary:: + + A10G + L4 + K80 + M60 + P4 + P100 + T4 + V100 + A100 + A100_80GB + +""" +import abc +import copy +from typing import ClassVar, Generic, Optional, Type, TypeVar + +from flyteidl.core import tasks_pb2 + +T = TypeVar("T") +MIG = TypeVar("MIG", bound="MultiInstanceGPUAccelerator") + + +class BaseAccelerator(abc.ABC, Generic[T]): + """ + Base class for all accelerator types. This class is not meant to be instantiated directly. + """ + + @abc.abstractmethod + def to_flyte_idl(self) -> T: + ... + + +class GPUAccelerator(BaseAccelerator): + """ + Class that represents a GPU accelerator. The class can be instantiated with any valid GPU device name, but + it is recommended to use one of the pre-defined constants below, as name has to match the name of the device + configured on the cluster. + """ + + def __init__(self, device: str) -> None: + self._device = device + + def to_flyte_idl(self) -> tasks_pb2.GPUAccelerator: + return tasks_pb2.GPUAccelerator(device=self._device) + + +#: use this constant to specify that the task should run on an +#: `NVIDIA A10 Tensor Core GPU `_ +A10G = GPUAccelerator("nvidia-a10g") + +#: use this constant to specify that the task should run on an +#: `NVIDIA L4 Tensor Core GPU `_ +L4 = GPUAccelerator("nvidia-l4-vws") + +#: use this constant to specify that the task should run on an +#: `NVIDIA Tesla K80 GPU `_ +K80 = GPUAccelerator("nvidia-tesla-k80") + +#: use this constant to specify that the task should run on an +#: `NVIDIA Tesla M60 GPU `_ +M60 = GPUAccelerator("nvidia-tesla-m60") + +#: use this constant to specify that the task should run on an +#: `NVIDIA Tesla P4 GPU `_ +P4 = GPUAccelerator("nvidia-tesla-p4") + +#: use this constant to specify that the task should run on an +#: `NVIDIA Tesla P100 GPU `_ +P100 = GPUAccelerator("nvidia-tesla-p100") + +#: use this constant to specify that the task should run on an +#: `NVIDIA T4 Tensor Core GPU `_ +T4 = GPUAccelerator("nvidia-tesla-t4") + +#: use this constant to specify that the task should run on an +#: `NVIDIA Tesla V100 GPU `_ +V100 = GPUAccelerator("nvidia-tesla-v100") + + +class MultiInstanceGPUAccelerator(BaseAccelerator): + """ + Base class for all multi-instance GPU accelerator types. It is recommended to use one of the pre-defined constants + below, as name has to match the name of the device configured on the cluster. + For example, to specify a 10GB partition of an A100 GPU, use ``A100.partition_2g_10gb``. + """ + + device: ClassVar[str] + _partition_size: Optional[str] + + @property + def unpartitioned(self: MIG) -> MIG: + instance = copy.deepcopy(self) + instance._partition_size = None + return instance + + @classmethod + def partitioned(cls: Type[MIG], partition_size: str) -> MIG: + instance = cls() + instance._partition_size = partition_size + return instance + + def to_flyte_idl(self) -> tasks_pb2.GPUAccelerator: + msg = tasks_pb2.GPUAccelerator(device=self.device) + if not hasattr(self, "_partition_size"): + return msg + + if self._partition_size is None: + msg.unpartitioned = True + else: + msg.partition_size = self._partition_size + return msg + + +class _A100_Base(MultiInstanceGPUAccelerator): + device = "nvidia-tesla-a100" + + +class _A100(_A100_Base): + """ + Class that represents an `NVIDIA A100 GPU `_. It is possible + to specify a partition of an A100 GPU by using the provided partitions on the class. For example, to specify a + 10GB partition, use ``A100.partition_2g_10gb``. + Refer to `Partitioned GPUs `_ + """ + + partition_1g_5gb = _A100_Base.partitioned("1g.5gb") + """ + 5GB partition of an A100 GPU. + """ + partition_2g_10gb = _A100_Base.partitioned("2g.10gb") + """ + 10GB partition of an A100 GPU - 2x5GB slices with 2/7th of the SM. + """ + partition_3g_20gb = _A100_Base.partitioned("3g.20gb") + """ + 20GB partition of an A100 GPU - 4x5GB slices, with 3/7th fraction of SM (Streaming multiprocessor). + """ + partition_4g_20gb = _A100_Base.partitioned("4g.20gb") + """ + 20GB partition of an A100 GPU - 4x5GB slices, with 4/7th fraction of SM. + """ + partition_7g_40gb = _A100_Base.partitioned("7g.40gb") + """ + 40GB partition of an A100 GPU - 8x5GB slices, with 7/7th fraction of SM. + """ + + +#: Use this constant to specify that the task should run on an entire +#: `NVIDIA A100 GPU `_. Fractional partitions are also available. +#: +#: Use pre-defined partitions (as instance attributes). For example, to specify a 10GB partition, use +#: ``A100.partition_2g_10gb``. +#: All partitions are nested in the class as follows: +#: +#: .. autoclass:: _A100 +#: :members: +A100 = _A100() + + +class _A100_80GB_Base(MultiInstanceGPUAccelerator): + device = "nvidia-a100-80gb" + + +class _A100_80GB(_A100_80GB_Base): + """ + Partitions of an `NVIDIA A100 80GB GPU `_. + """ + + partition_1g_10gb = _A100_80GB_Base.partitioned("1g.10gb") + """ + 10GB partition of an A100 80GB GPU - 2x5GB slices with 1/7th of the SM. + """ + partition_2g_20gb = _A100_80GB_Base.partitioned("2g.20gb") + """ + 2GB partition of an A100 80GB GPU - 4x5GB slices with 2/7th of the SM. + """ + partition_3g_40gb = _A100_80GB_Base.partitioned("3g.40gb") + """ + 3GB partition of an A100 80GB GPU - 8x5GB slices with 3/7th of the SM. + """ + partition_4g_40gb = _A100_80GB_Base.partitioned("4g.40gb") + """ + 4GB partition of an A100 80GB GPU - 8x5GB slices with 4/7th of the SM. + """ + partition_7g_80gb = _A100_80GB_Base.partitioned("7g.80gb") + """ + 7GB partition of an A100 80GB GPU - 16x5GB slices with 7/7th of the SM. + """ + + +#: use this constant to specify that the task should run on an entire +#: `NVIDIA A100 80GB GPU `_. Fractional partitions are also available. +#: +#: Use pre-defined partitions (as instance attributes). For example, to specify a 10GB partition, use +#: ``A100.partition_2g_10gb``. +#: All available partitions are listed below: +#: +#: .. autoclass:: _A100_80GB +#: :members: +A100_80GB = _A100_80GB() diff --git a/flytekit/flytekit/extras/cloud_pickle_resolver.py b/flytekit/flytekit/extras/cloud_pickle_resolver.py new file mode 100644 index 0000000000..94a7a34d83 --- /dev/null +++ b/flytekit/flytekit/extras/cloud_pickle_resolver.py @@ -0,0 +1,38 @@ +from base64 import b64decode, b64encode +from typing import List + +import cloudpickle + +from flytekit.configuration import SerializationSettings +from flytekit.core.base_task import TaskResolverMixin +from flytekit.core.python_auto_container import PythonAutoContainerTask +from flytekit.core.tracker import TrackedInstance + + +class ExperimentalNaiveCloudPickleResolver(TrackedInstance, TaskResolverMixin): + """ + Please do not use this resolver, basically ever. This is here for demonstration purposes only. The critical flaw + of this resolver is that pretty much any task that it resolves results in loader_args that are enormous. This + payload is serialized as part of the ``TaskTemplate`` protobuf object and will live in Admin and then be loaded + into Flyte Propeller memory and will pretty much clog up performance along the entire platform. + + TODO: Replace this with a version that will upload the data to S3 or some other durable store upon ``loader_args`` + and will download the data upon ``load_task``. This will require additional changes to Admin however. + """ + + def name(self) -> str: + return "cloud pickling task resolver" + + def load_task(self, loader_args: List[str]) -> PythonAutoContainerTask: + raw_bytes = loader_args[0].encode("ascii") + pickled = b64decode(raw_bytes) + return cloudpickle.loads(pickled) + + def loader_args(self, settings: SerializationSettings, t: PythonAutoContainerTask) -> List[str]: + return [b64encode(cloudpickle.dumps(t)).decode("ascii")] + + def get_all_tasks(self) -> List[PythonAutoContainerTask]: + pass + + +experimental_cloud_pickle_resolver = ExperimentalNaiveCloudPickleResolver() diff --git a/flytekit/flytekit/extras/pytorch/__init__.py b/flytekit/flytekit/extras/pytorch/__init__.py new file mode 100644 index 0000000000..a29d8e89e6 --- /dev/null +++ b/flytekit/flytekit/extras/pytorch/__init__.py @@ -0,0 +1,32 @@ +""" +.. currentmodule:: flytekit.extras.pytorch + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + PyTorchCheckpoint + PyTorchCheckpointTransformer + PyTorchModuleTransformer + PyTorchTensorTransformer +""" +from flytekit.loggers import logger + +# TODO: abstract this out so that there's an established pattern for registering plugins +# that have soft dependencies +try: + # isolate the exception to the torch import + import torch + + _torch_installed = True +except (ImportError, OSError): + _torch_installed = False + + +if _torch_installed: + from .checkpoint import PyTorchCheckpoint, PyTorchCheckpointTransformer + from .native import PyTorchModuleTransformer, PyTorchTensorTransformer +else: + logger.info( + "We won't register PyTorchCheckpointTransformer, PyTorchTensorTransformer, and PyTorchModuleTransformer because torch is not installed." + ) diff --git a/flytekit/flytekit/extras/pytorch/checkpoint.py b/flytekit/flytekit/extras/pytorch/checkpoint.py new file mode 100644 index 0000000000..dfb21f5932 --- /dev/null +++ b/flytekit/flytekit/extras/pytorch/checkpoint.py @@ -0,0 +1,135 @@ +import pathlib +import typing +from dataclasses import asdict, dataclass, fields, is_dataclass +from typing import Any, Callable, Dict, NamedTuple, Optional, Type, Union + +import torch +from dataclasses_json import DataClassJsonMixin +from typing_extensions import Protocol + +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 IsDataclass(Protocol): + __dataclass_fields__: Dict + __dataclass_params__: Dict + __post_init__: Optional[Callable] + + +@dataclass +class PyTorchCheckpoint(DataClassJsonMixin): + """ + This class is helpful to save a checkpoint. + """ + + module: Optional[torch.nn.Module] = None + hyperparameters: Optional[Union[Dict[str, Any], NamedTuple, IsDataclass]] = None + optimizer: Optional[torch.optim.Optimizer] = None + + def __post_init__(self): + if not ( + isinstance(self.hyperparameters, dict) + or (is_dataclass(self.hyperparameters) and not isinstance(self.hyperparameters, type)) + or (isinstance(self.hyperparameters, tuple) and hasattr(self.hyperparameters, "_fields")) + or (self.hyperparameters is None) + ): + raise TypeTransformerFailedError( + f"hyperparameters must be a dict, dataclass, or NamedTuple. Got {type(self.hyperparameters)}" + ) + + if not (self.module or self.hyperparameters or self.optimizer): + raise TypeTransformerFailedError("Must have at least one of module, hyperparameters, or optimizer") + + +class PyTorchCheckpointTransformer(TypeTransformer[PyTorchCheckpoint]): + """ + TypeTransformer that supports serializing and deserializing checkpoint. + """ + + PYTORCH_CHECKPOINT_FORMAT = "PyTorchCheckpoint" + + def __init__(self): + super().__init__(name="PyTorch Checkpoint", t=PyTorchCheckpoint) + + def get_literal_type(self, t: Type[PyTorchCheckpoint]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.PYTORCH_CHECKPOINT_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + + def to_literal( + self, + ctx: FlyteContext, + python_val: PyTorchCheckpoint, + python_type: Type[PyTorchCheckpoint], + expected: LiteralType, + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.PYTORCH_CHECKPOINT_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + + local_path = ctx.file_access.get_random_local_path() + ".pt" + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + + to_save = {} + for field in fields(python_val): + value = getattr(python_val, field.name) + + if value and field.name in ["module", "optimizer"]: + to_save[field.name + "_state_dict"] = getattr(value, "state_dict")() + elif value and field.name == "hyperparameters": + if isinstance(value, dict): + to_save.update(value) + elif isinstance(value, tuple): + to_save.update(value._asdict()) + elif is_dataclass(value): + to_save.update(asdict(value)) + + if not to_save: + raise TypeTransformerFailedError(f"Cannot save empty {python_val}") + + # save checkpoint to a file + torch.save(to_save, local_path) + + remote_path = ctx.file_access.put_raw_data(local_path) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[PyTorchCheckpoint] + ) -> PyTorchCheckpoint: + 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=False) + + # cpu <-> gpu conversion + if torch.cuda.is_available(): + map_location = "cuda:0" + else: + map_location = torch.device("cpu") + + # load checkpoint from a file + return typing.cast(PyTorchCheckpoint, torch.load(local_path, map_location=map_location)) + + def guess_python_type(self, literal_type: LiteralType) -> Type[PyTorchCheckpoint]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.PYTORCH_CHECKPOINT_FORMAT + ): + return PyTorchCheckpoint + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(PyTorchCheckpointTransformer()) diff --git a/flytekit/flytekit/extras/pytorch/native.py b/flytekit/flytekit/extras/pytorch/native.py new file mode 100644 index 0000000000..e18b367224 --- /dev/null +++ b/flytekit/flytekit/extras/pytorch/native.py @@ -0,0 +1,101 @@ +import pathlib +from typing import Type, TypeVar + +import torch + +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 + +T = TypeVar("T") + + +class PyTorchTypeTransformer(TypeTransformer[T]): + def get_literal_type(self, t: Type[T]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.PYTORCH_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + ) + + def to_literal( + self, + ctx: FlyteContext, + python_val: T, + python_type: Type[T], + expected: LiteralType, + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.PYTORCH_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + ) + + local_path = ctx.file_access.get_random_local_path() + ".pt" + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + + # save pytorch tensor/module to a file + torch.save(python_val, local_path) + + remote_path = ctx.file_access.put_raw_data(local_path) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + 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=False) + + # cpu <-> gpu conversion + if torch.cuda.is_available(): + map_location = "cuda:0" + else: + map_location = torch.device("cpu") + + # load pytorch tensor/module from a file + return torch.load(local_path, map_location=map_location) + + +class PyTorchTensorTransformer(PyTorchTypeTransformer[torch.Tensor]): + PYTORCH_FORMAT = "PyTorchTensor" + + def __init__(self): + super().__init__(name="PyTorch Tensor", t=torch.Tensor) + + def guess_python_type(self, literal_type: LiteralType) -> Type[torch.Tensor]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.PYTORCH_FORMAT + ): + return torch.Tensor + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +class PyTorchModuleTransformer(PyTorchTypeTransformer[torch.nn.Module]): + PYTORCH_FORMAT = "PyTorchModule" + + def __init__(self): + super().__init__(name="PyTorch Module", t=torch.nn.Module) + + def guess_python_type(self, literal_type: LiteralType) -> Type[torch.nn.Module]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.PYTORCH_FORMAT + ): + return torch.nn.Module + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(PyTorchTensorTransformer()) +TypeEngine.register(PyTorchModuleTransformer()) diff --git a/flytekit/flytekit/extras/sklearn/__init__.py b/flytekit/flytekit/extras/sklearn/__init__.py new file mode 100644 index 0000000000..1d16f6080f --- /dev/null +++ b/flytekit/flytekit/extras/sklearn/__init__.py @@ -0,0 +1,26 @@ +""" +.. currentmodule:: flytekit.extras.sklearn + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + SklearnEstimatorTransformer +""" +from flytekit.loggers import logger + +# TODO: abstract this out so that there's an established pattern for registering plugins +# that have soft dependencies +try: + # isolate the exception to the sklearn import + import sklearn + + _sklearn_installed = True +except (ImportError, OSError): + _sklearn_installed = False + + +if _sklearn_installed: + from .native import SklearnEstimatorTransformer +else: + logger.info("We won't register SklearnEstimatorTransformer because scikit-learn is not installed.") diff --git a/flytekit/flytekit/extras/sklearn/native.py b/flytekit/flytekit/extras/sklearn/native.py new file mode 100644 index 0000000000..37426fdfa4 --- /dev/null +++ b/flytekit/flytekit/extras/sklearn/native.py @@ -0,0 +1,88 @@ +import pathlib +from typing import Type, TypeVar + +import joblib +import sklearn + +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 + +T = TypeVar("T") + + +class SklearnTypeTransformer(TypeTransformer[T]): + def get_literal_type(self, t: Type[T]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.SKLEARN_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + ) + + def to_literal( + self, + ctx: FlyteContext, + python_val: T, + python_type: Type[T], + expected: LiteralType, + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.SKLEARN_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + ) + + local_path = ctx.file_access.get_random_local_path() + ".joblib" + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + + # save sklearn estimator to a file + joblib.dump(python_val, local_path) + + remote_path = ctx.file_access.put_raw_data(local_path) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + 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=False) + + # load sklearn estimator from a file + return joblib.load(local_path) + + def guess_python_type(self, literal_type: LiteralType) -> Type[T]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.SKLEARN_FORMAT + ): + return T + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +class SklearnEstimatorTransformer(SklearnTypeTransformer[sklearn.base.BaseEstimator]): + SKLEARN_FORMAT = "SklearnEstimator" + + def __init__(self): + super().__init__(name="Sklearn Estimator", t=sklearn.base.BaseEstimator) + + def guess_python_type(self, literal_type: LiteralType) -> Type[sklearn.base.BaseEstimator]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.SKLEARN_FORMAT + ): + return sklearn.base.BaseEstimator + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(SklearnEstimatorTransformer()) diff --git a/flytekit/flytekit/extras/sqlite3/__init__.py b/flytekit/flytekit/extras/sqlite3/__init__.py new file mode 100644 index 0000000000..cc016ac4af --- /dev/null +++ b/flytekit/flytekit/extras/sqlite3/__init__.py @@ -0,0 +1,12 @@ +""" +Flytekit SQLite3Task +========================================= +.. currentmodule:: flytekit.extras.sqlite3 + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + ~task.SQLite3Task + ~task.SQLite3Config +""" diff --git a/flytekit/flytekit/extras/sqlite3/task.py b/flytekit/flytekit/extras/sqlite3/task.py new file mode 100644 index 0000000000..51dcaccd61 --- /dev/null +++ b/flytekit/flytekit/extras/sqlite3/task.py @@ -0,0 +1,135 @@ +import contextlib +import os +import shutil +import sqlite3 +import tempfile +import typing +from dataclasses import dataclass + +from flytekit import FlyteContext, kwtypes, lazy_module +from flytekit.configuration import DefaultImages, SerializationSettings +from flytekit.core.base_sql_task import SQLTask +from flytekit.core.python_customized_container_task import PythonCustomizedContainerTask +from flytekit.core.shim_task import ShimTaskExecutor +from flytekit.models import task as task_models + +if typing.TYPE_CHECKING: + import pandas as pd +else: + pd = lazy_module("pandas") + + +def unarchive_file(local_path: str, to_dir: str): + """ + Unarchive given archive and returns the unarchived file name. It is expected that only one file is unarchived. + More than one file or 0 files will result in a ``RuntimeError`` + """ + archive_dir = os.path.join(to_dir, "_arch") + shutil.unpack_archive(local_path, archive_dir) + # file gets uncompressed into to_dir/_arch/*.* + files = os.listdir(archive_dir) + if not files or len(files) == 0 or len(files) > 1: + raise RuntimeError(f"Uncompressed archive should contain only one file - found {files}!") + return os.path.join(archive_dir, files[0]) + + +@dataclass +class SQLite3Config(object): + """ + Use this configuration to configure if sqlite3 files that should be loaded by the task. The file itself is + considered as a database and hence is treated like a configuration + The path to a static sqlite3 compatible database file can be + + - within the container + - or from a publicly downloadable source + + Args: + uri: default FlyteFile that will be downloaded on execute + compressed: Boolean that indicates if the given file is a compressed archive. Supported file types are + [zip, tar, gztar, bztar, xztar] + """ + + uri: str + compressed: bool = False + + +class SQLite3Task(PythonCustomizedContainerTask[SQLite3Config], SQLTask[SQLite3Config]): + """ + Run client side SQLite3 queries that optionally return a FlyteSchema object. + + .. note:: + + This is a pre-built container task. That is, your user container will not be used at task execution time. + Instead the image defined in this task definition will be used instead. + + .. literalinclude:: ../../../tests/flytekit/unit/extras/sqlite3/test_task.py + :start-after: # sqlite3_start + :end-before: # sqlite3_end + :language: python + :dedent: 4 + + See the :ref:`integrations guide ` for additional usage examples and + the base class :py:class:`flytekit.extend.PythonCustomizedContainerTask` as well. + """ + + _SQLITE_TASK_TYPE = "sqlite" + + def __init__( + self, + name: str, + 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, # 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, + task_config=task_config, + # if you use your own image, keep in mind to specify the container image here + container_image=container_image or DefaultImages.default_image(), + executor_type=SQLite3TaskExecutor, + task_type=self._SQLITE_TASK_TYPE, + # Sanitize query by removing the newlines at the end of the query. Keep in mind + # that the query can be a multiline string. + query_template=query_template, + inputs=inputs, + outputs=outputs, + **kwargs, + ) + + @property + def output_columns(self) -> typing.Optional[typing.List[str]]: + c = self.python_interface.outputs["results"].column_names() + return c if c else None + + def get_custom(self, settings: SerializationSettings) -> typing.Dict[str, typing.Any]: + return { + "query_template": self.query_template, + "uri": self.task_config.uri, + "compressed": self.task_config.compressed, + } + + +class SQLite3TaskExecutor(ShimTaskExecutor[SQLite3Task]): + def execute_from_model(self, tt: task_models.TaskTemplate, **kwargs) -> typing.Any: + with tempfile.TemporaryDirectory() as temp_dir: + ctx = FlyteContext.current_context() + file_ext = os.path.basename(tt.custom["uri"]) + local_path = os.path.join(temp_dir, file_ext) + ctx.file_access.get_data(tt.custom["uri"], local_path) + if tt.custom["compressed"]: + local_path = unarchive_file(local_path, temp_dir) + + print(f"Connecting to db {local_path}") + interpolated_query = SQLite3Task.interpolate_query(tt.custom["query_template"], **kwargs) + print(f"Interpolated query {interpolated_query}") + with contextlib.closing(sqlite3.connect(local_path)) as con: + df = pd.read_sql_query(interpolated_query, con) + return df diff --git a/flytekit/flytekit/extras/tasks/__init__.py b/flytekit/flytekit/extras/tasks/__init__.py new file mode 100644 index 0000000000..94ca9244fd --- /dev/null +++ b/flytekit/flytekit/extras/tasks/__init__.py @@ -0,0 +1,12 @@ +""" +Flytekit ShellTask +========================================= +.. currentmodule:: flytekit.extras.tasks + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + ~shell.ShellTask + ~shell.OutputLocation +""" diff --git a/flytekit/flytekit/extras/tasks/shell.py b/flytekit/flytekit/extras/tasks/shell.py new file mode 100644 index 0000000000..bcb0d30709 --- /dev/null +++ b/flytekit/flytekit/extras/tasks/shell.py @@ -0,0 +1,473 @@ +import datetime +import os +import platform +import string +import subprocess +import typing +from dataclasses import dataclass +from typing import List, Tuple + +import flytekit +from flytekit.core.context_manager import ExecutionParameters +from flytekit.core.interface import Interface +from flytekit.core.python_function_task import PythonInstanceTask +from flytekit.core.task import TaskPlugins +from flytekit.exceptions.user import FlyteRecoverableException +from flytekit.loggers import logger +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import FlyteFile + + +@dataclass +class OutputLocation: + """ + Args: + var: str The name of the output variable + var_type: typing.Type The type of output variable + location: os.PathLike The location where this output variable will be written to or a regex that accepts input + vars and generates the path. Of the form ``"{{ .inputs.v }}.tmp.md"``. + This example for a given input v, at path `/tmp/abc.csv` will resolve to `/tmp/abc.csv.tmp.md` + """ + + var: str + var_type: typing.Type + location: typing.Union[os.PathLike, str] + + +def subproc_execute(command: typing.Union[List[str], str], **kwargs) -> Tuple[str, str]: + """ + Execute a command and capture its stdout and stderr. Useful for executing + shell commands from within a python task. + + Args: + command (List[str]): The command to be executed as a list of strings. + + Returns: + Tuple[str, str]: A tuple containing the stdout and stderr output of the command. + + Raises: + Exception: If the command execution fails, this exception is raised with + details about the command, return code, and stderr output. + Exception: If the executable is not found, this exception is raised with + guidance on specifying a container image in the task definition when + using custom dependencies. + """ + defaults = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "text": True, + "check": True, + } + + kwargs = {**defaults, **kwargs} + + try: + # Execute the command and capture stdout and stderr + result = subprocess.run(command, **kwargs) + + # Access the stdout and stderr output + return result.stdout, result.stderr + + except subprocess.CalledProcessError as e: + raise Exception(f"Command: {e.cmd}\nFailed with return code {e.returncode}:\n{e.stderr}") + + except FileNotFoundError as e: + raise Exception( + f"""Process failed because the executable could not be found. + Did you specify a container image in the task definition if using + custom dependencies?\n{e}""" + ) + + +def _dummy_task_func(): + """ + A Fake function to satisfy the inner PythonTask requirements + """ + return None + + +class AttrDict(dict): + """ + Convert a dictionary to an attribute style lookup. Do not use this in regular places, this is used for + namespacing inputs and outputs + """ + + def __init__(self, *args, **kwargs): + super(AttrDict, self).__init__(*args, **kwargs) + self.__dict__ = self + + +class _PythonFStringInterpolizer: + """A class for interpolating scripts that use python string.format syntax""" + + class _Formatter(string.Formatter): + def format_field(self, value, format_spec): + """ + Special cased return for the given value. Given the type returns the string version for + the type. Handles FlyteFile and FlyteDirectory specially. + Downloads and returns the downloaded filepath. + """ + if isinstance(value, FlyteFile): + value.download() + return value.path + if isinstance(value, FlyteDirectory): + value.download() + return value.path + if isinstance(value, datetime.datetime): + return value.isoformat() + return super().format_field(value, format_spec) + + def interpolate( + self, + tmpl: str, + inputs: typing.Optional[typing.Dict[str, str]] = None, + outputs: typing.Optional[typing.Dict[str, str]] = None, + ) -> str: + """ + Interpolate python formatted string templates with variables from the input and output + argument dicts. The result is non destructive towards the given template string. + """ + inputs = inputs or {} + outputs = outputs or {} + inputs = AttrDict(inputs) + outputs = AttrDict(outputs) + consolidated_args = { + "inputs": inputs, + "outputs": outputs, + "ctx": flytekit.current_context(), + } + try: + return self._Formatter().format(tmpl, **consolidated_args) + except KeyError as e: + raise ValueError(f"Variable {e} in Query not found in inputs {consolidated_args.keys()}") + + +T = typing.TypeVar("T") + + +def _run_script(script: str, shell: str) -> typing.Tuple[int, str, str]: + """ + Run script as a subprocess and return the returncode, stdout, and stderr. + + While executing the su process, stdout of the subprocess will be printed + to the current process stdout so that the subprocess execution will not appear unresponsive + + :param script: script to be executed + :type script: str + :param shell: shell to use to run the script + :type shell: str + :return: tuple containing the process returncode, stdout, and stderr + :rtype: typing.Tuple[int, str, str] + """ + process = subprocess.Popen( + script, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + shell=True, + text=True, + executable=shell, + ) + + process_stdout, process_stderr = process.communicate() + out = "" + for line in process_stdout.splitlines(): + print(line) + out += line + + code = process.wait() + return code, out, process_stderr + + +class ShellTask(PythonInstanceTask[T]): + """ """ + + def __init__( + self, + name: str, + debug: bool = False, + script: typing.Optional[str] = None, + script_file: typing.Optional[str] = None, + task_config: T = None, + shell: str = "/bin/sh", + inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, + output_locs: typing.Optional[typing.List[OutputLocation]] = None, + **kwargs, + ): + """ + Args: + name: str Name of the Task. Should be unique in the project + debug: bool Print the generated script and other debugging information + script: The actual script specified as a string + script_file: A path to the file that contains the script (Only script or script_file) can be provided + task_config: Configuration for the task, can be either a Pod (or coming soon, BatchJob) config + shell: Shell to use to run the script + inputs: A Dictionary of input names to types + output_locs: A list of :py:class:`OutputLocations` + **kwargs: Other arguments that can be passed to + :py:class:`~flytekit.core.python_function_task.PythonInstanceTask` + """ + if script and script_file: + raise ValueError("Only either of script or script_file can be provided") + if not script and not script_file: + raise ValueError("Either a script or script_file is needed") + if script_file: + if not os.path.exists(script_file): + raise ValueError(f"FileNotFound: the specified Script file at path {script_file} cannot be loaded") + script_file = os.path.abspath(script_file) + + if task_config is not None: + fully_qualified_class_name = task_config.__module__ + "." + task_config.__class__.__name__ + if not fully_qualified_class_name == "flytekitplugins.pod.task.Pod": + raise ValueError("TaskConfig can either be empty - indicating simple container task or a PodConfig.") + + # Each instance of NotebookTask instantiates an underlying task with a dummy function that will only be used + # to run pre- and post- execute functions using the corresponding task plugin. + # We rename the function name here to ensure the generated task has a unique name and avoid duplicate task name + # errors. + # This seem like a hack. We should use a plugin_class that doesn't require a fake-function to make work. + plugin_class = TaskPlugins.find_pythontask_plugin(type(task_config)) + self._config_task_instance = plugin_class(task_config=task_config, task_function=_dummy_task_func) + # Rename the internal task so that there are no conflicts at serialization time. Technically these internal + # tasks should not be serialized at all, but we don't currently have a mechanism for skipping Flyte entities + # at serialization time. + self._config_task_instance._name = f"_bash.{name}" + self._script = script + self._script_file = script_file + self._debug = debug + self._shell = shell + self._output_locs = output_locs if output_locs else [] + self._interpolizer = _PythonFStringInterpolizer() + outputs = self._validate_output_locs() + super().__init__( + name, + task_config, + task_type=self._config_task_instance.task_type, + interface=Interface(inputs=inputs, outputs=outputs), + **kwargs, + ) + + def _validate_output_locs(self) -> typing.Dict[str, typing.Type]: + outputs = {} + for v in self._output_locs: + if v is None: + raise ValueError("OutputLocation cannot be none") + if not isinstance(v, OutputLocation): + raise ValueError("Every output type should be an output location on the file-system") + if v.location is None: + raise ValueError(f"Output Location not provided for output var {v.var}") + if not issubclass(v.var_type, FlyteFile) and not issubclass(v.var_type, FlyteDirectory): + raise ValueError( + "Currently only outputs of type FlyteFile/FlyteDirectory and their derived types are supported" + ) + outputs[v.var] = v.var_type + return outputs + + @property + def script(self) -> typing.Optional[str]: + return self._script + + @property + def script_file(self) -> typing.Optional[os.PathLike]: + return self._script_file + + def pre_execute(self, user_params: ExecutionParameters) -> ExecutionParameters: + return self._config_task_instance.pre_execute(user_params) + + def execute(self, **kwargs) -> typing.Any: + """ + Executes the given script by substituting the inputs and outputs and extracts the outputs from the filesystem + """ + logger.info(f"Running shell script as type {self.task_type}") + if self.script_file: + with open(self.script_file) as f: + self._script = f.read() + + outputs: typing.Dict[str, str] = {} + if self._output_locs: + for v in self._output_locs: + outputs[v.var] = self._interpolizer.interpolate(v.location, inputs=kwargs) + + if os.name == "nt": + self._script = self._script.lstrip().rstrip().replace("\n", "&&") + + gen_script = self._interpolizer.interpolate(self._script, inputs=kwargs, outputs=outputs) + if self._debug: + print("\n==============================================\n") + print(gen_script) + print("\n==============================================\n") + + if platform.system() == "Windows": + if os.environ.get("ComSpec") is None: + # https://github.com/python/cpython/issues/101283 + os.environ["ComSpec"] = "C:\\Windows\\System32\\cmd.exe" + self._shell = os.environ["ComSpec"] + + returncode, stdout, stderr = _run_script(gen_script, self._shell) + if returncode != 0: + files = os.listdir(".") + fstr = "\n-".join(files) + error = ( + f"Failed to Execute Script, return-code {returncode} \n" + f"Current directory contents: .\n-{fstr}\n" + f"StdOut: {stdout}\n" + f"StdErr: {stderr}\n" + ) + logger.error(error) + # raise FlyteRecoverableException so that it's classified as user error and will be retried + raise FlyteRecoverableException(error) + + final_outputs = [] + for v in self._output_locs: + if issubclass(v.var_type, FlyteFile): + final_outputs.append(FlyteFile(outputs[v.var])) + if issubclass(v.var_type, FlyteDirectory): + final_outputs.append(FlyteDirectory(outputs[v.var])) + if len(final_outputs) == 1: + return final_outputs[0] + if len(final_outputs) > 1: + return tuple(final_outputs) + return None + + def post_execute(self, user_params: ExecutionParameters, rval: typing.Any) -> typing.Any: + return self._config_task_instance.post_execute(user_params, rval) + + +class RawShellTask(ShellTask): + """ """ + + def __init__( + self, + name: str, + debug: bool = False, + script: typing.Optional[str] = None, + script_file: typing.Optional[str] = None, + task_config: T = None, + inputs: typing.Optional[typing.Dict[str, typing.Type]] = None, + output_locs: typing.Optional[typing.List[OutputLocation]] = None, + **kwargs, + ): + """ + The `RawShellTask` is a minimal extension of the existing `ShellTask`. It's purpose is to support wrapping a + "raw" or "pure" shell script which needs to be executed with some environment variables set, and some arguments, + which may not be known until execution time. + + This class is not meant to be instantiated into tasks by users, but used with the factory function + `get_raw_shell_task()`. An instance of this class will be returned with either user-specified or default + template. The template itself will export the desired environment variables, and subsequently execute the + desired "raw" script with the specified arguments. + + .. note:: + This means that within your workflow, you can dynamically control the env variables, arguments, and even the + actual script you want to run. + + .. note:: + The downside is that a dynamic workflow will be required. The "raw" script passed in at execution time must + be at the specified location. + + These args are forwarded directly to the parent `ShellTask` constructor as behavior does not diverge + """ + super().__init__( + name=name, + debug=debug, + script=script, + script_file=script_file, + task_config=task_config, + inputs=inputs, + output_locs=output_locs, + **kwargs, + ) + + def make_export_string_from_env_dict(self, d: typing.Dict[str, str]) -> str: + """ + Utility function to convert a dictionary of desired environment variable key: value pairs into a string of + ``` + export k1=v1 + export k2=v2 + ... + ``` + """ + items = [] + for k, v in d.items(): + items.append(f"export {k}={v}") + return "\n".join(items) + + def execute(self, **kwargs) -> typing.Any: + """ + Executes the given script by substituting the inputs and outputs and extracts the outputs from the filesystem + """ + logger.info(f"Running shell script as type {self.task_type}") + if self.script_file: + with open(self.script_file) as f: + self._script = f.read() + + outputs: typing.Dict[str, str] = {} + if self._output_locs: + for v in self._output_locs: + outputs[v.var] = self._interpolizer.interpolate(v.location, inputs=kwargs) + + if os.name == "nt": + self._script = self._script.lstrip().rstrip().replace("\n", "&&") + + if "env" in kwargs and isinstance(kwargs["env"], dict): + kwargs["export_env"] = self.make_export_string_from_env_dict(kwargs["env"]) + + gen_script = self._interpolizer.interpolate(self._script, inputs=kwargs, outputs=outputs) + if self._debug: + print("\n==============================================\n") + print(gen_script) + print("\n==============================================\n") + + try: + subprocess.check_call(gen_script, shell=True) + except subprocess.CalledProcessError as e: + files = os.listdir(".") + fstr = "\n-".join(files) + logger.error( + f"Failed to Execute Script, return-code {e.returncode} \n" + f"StdErr: {e.stderr}\n" + f"StdOut: {e.stdout}\n" + f" Current directory contents: .\n-{fstr}" + ) + raise + + final_outputs = [] + for v in self._output_locs: + if issubclass(v.var_type, FlyteFile): + final_outputs.append(FlyteFile(outputs[v.var])) + if issubclass(v.var_type, FlyteDirectory): + final_outputs.append(FlyteDirectory(outputs[v.var])) + if len(final_outputs) == 1: + return final_outputs[0] + if len(final_outputs) > 1: + return tuple(final_outputs) + return None + + +# The raw_shell_task is an instance of RawShellTask and wraps a 'pure' shell script +# 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, + inputs=flytekit.kwtypes(env=typing.Dict[str, str], script_args=str, script_file=str), + output_locs=[ + OutputLocation( + var="out", + var_type=FlyteDirectory, + location="{ctx.working_directory}", + ) + ], + script=""" +#!/bin/bash + +set -uex + +cd {ctx.working_directory} + +{inputs.export_env} + +bash {inputs.script_file} {inputs.script_args} +""", + ) diff --git a/flytekit/flytekit/extras/tensorflow/__init__.py b/flytekit/flytekit/extras/tensorflow/__init__.py new file mode 100644 index 0000000000..0c4d12f443 --- /dev/null +++ b/flytekit/flytekit/extras/tensorflow/__init__.py @@ -0,0 +1,35 @@ +""" +.. currentmodule:: flytekit.extras.tensorflow + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + TensorFlowRecordFileTransformer + TensorFlowRecordsDirTransformer +""" + +from flytekit.loggers import logger + +# TODO: abstract this out so that there's an established pattern for registering plugins +# that have soft dependencies +try: + # isolate the exception to the tensorflow import + import tensorflow + + _tensorflow_installed = True +except TypeError as e: + logger.warn(f"Unsupported version of tensorflow installed. Error message from protobuf library: {e}") + _tensorflow_installed = False +except (ImportError, OSError): + _tensorflow_installed = False + + +if _tensorflow_installed: + from .model import TensorFlowModelTransformer + from .record import TensorFlowRecordFileTransformer, TensorFlowRecordsDirTransformer +else: + logger.info( + "We won't register TensorFlowRecordFileTransformer, TensorFlowRecordsDirTransformer and TensorFlowModelTransformer" + "because tensorflow is not installed." + ) diff --git a/flytekit/flytekit/extras/tensorflow/model.py b/flytekit/flytekit/extras/tensorflow/model.py new file mode 100644 index 0000000000..2978fe1d69 --- /dev/null +++ b/flytekit/flytekit/extras/tensorflow/model.py @@ -0,0 +1,75 @@ +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.put_raw_data(local_path) + 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/flytekit/extras/tensorflow/record.py b/flytekit/flytekit/extras/tensorflow/record.py new file mode 100644 index 0000000000..3e86b6b2ee --- /dev/null +++ b/flytekit/flytekit/extras/tensorflow/record.py @@ -0,0 +1,184 @@ +import os +from dataclasses import dataclass +from typing import Optional, Tuple, Type, Union + +import tensorflow as tf +from dataclasses_json import DataClassJsonMixin +from tensorflow.python.data.ops.readers import TFRecordDatasetV2 +from typing_extensions import Annotated, get_args, get_origin + +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 +from flytekit.types.directory import TFRecordsDirectory +from flytekit.types.file import TFRecordFile + + +@dataclass +class TFRecordDatasetConfig(DataClassJsonMixin): + """ + TFRecordDatasetConfig can be used while creating tf.data.TFRecordDataset comprising + record of one or more TFRecord files. + + Args: + compression_type: A scalar evaluating to one of "" (no compression), "ZLIB", or "GZIP". + buffer_size: The number of bytes in the read buffer. If None, a sensible default for both local and remote file systems is used. + num_parallel_reads: The number of files to read in parallel. If greater than one, the record of files read in parallel are outputted in an interleaved order. + name: A name for the operation. + """ + + compression_type: Optional[str] = None + buffer_size: Optional[int] = None + num_parallel_reads: Optional[int] = None + name: Optional[str] = None + + +def extract_metadata_and_uri( + lv: Literal, t: Type[Union[TFRecordFile, TFRecordsDirectory]] +) -> Tuple[Union[TFRecordFile, TFRecordsDirectory], TFRecordDatasetConfig]: + try: + uri = lv.scalar.blob.uri + except AttributeError: + TypeTransformerFailedError(f"Cannot convert from {lv} to {t}") + metadata = TFRecordDatasetConfig() + if get_origin(t) is Annotated: + _, metadata = get_args(t) + if isinstance(metadata, TFRecordDatasetConfig): + return uri, metadata + else: + raise TypeTransformerFailedError(f"{t}'s metadata needs to be of type TFRecordDatasetConfig") + return uri, metadata + + +class TensorFlowRecordFileTransformer(TypeTransformer[TFRecordFile]): + """ + TypeTransformer that supports serialising and deserialising to and from TFRecord file. + https://www.tensorflow.org/tutorials/load_data/tfrecord + """ + + TENSORFLOW_FORMAT = "TensorFlowRecord" + + def __init__(self): + super().__init__(name="TensorFlow Record File", t=TFRecordFile) + + def get_literal_type(self, t: Type[TFRecordFile]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.TENSORFLOW_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + ) + + def to_literal( + self, ctx: FlyteContext, python_val: TFRecordFile, python_type: Type[TFRecordFile], expected: LiteralType + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.TENSORFLOW_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE, + ) + ) + local_dir = ctx.file_access.get_random_local_directory() + local_path = os.path.join(local_dir, "0000.tfrecord") + with tf.io.TFRecordWriter(local_path) as writer: + writer.write(python_val.SerializeToString()) + remote_path = ctx.file_access.put_raw_data(local_path) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[TFRecordFile] + ) -> TFRecordDatasetV2: + uri, metadata = extract_metadata_and_uri(lv, expected_python_type) + local_path = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(uri, local_path, is_multipart=False) + filenames = [local_path] + return tf.data.TFRecordDataset( + filenames=filenames, + compression_type=metadata.compression_type, + buffer_size=metadata.buffer_size, + num_parallel_reads=metadata.num_parallel_reads, + name=metadata.name, + ) + + def guess_python_type(self, literal_type: LiteralType) -> Type[TFRecordFile]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.TENSORFLOW_FORMAT + ): + return TFRecordFile + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +class TensorFlowRecordsDirTransformer(TypeTransformer[TFRecordsDirectory]): + """ + TypeTransformer that supports serialising and deserialising to and from TFRecord directory. + https://www.tensorflow.org/tutorials/load_data/tfrecord + """ + + TENSORFLOW_FORMAT = "TensorFlowRecord" + + def __init__(self): + super().__init__(name="TensorFlow Record Directory", t=TFRecordsDirectory) + + def get_literal_type(self, t: Type[TFRecordsDirectory]) -> 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: TFRecordsDirectory, + python_type: Type[TFRecordsDirectory], + expected: LiteralType, + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.TENSORFLOW_FORMAT, + dimensionality=_core_types.BlobType.BlobDimensionality.MULTIPART, + ) + ) + local_dir = ctx.file_access.get_random_local_directory() + for i, val in enumerate(python_val): + local_path = f"{local_dir}/part_{i}.tfrecord" + with tf.io.TFRecordWriter(local_path) as writer: + writer.write(val.SerializeToString()) + remote_path = ctx.file_access.put_raw_data(local_dir) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + 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) + files = os.scandir(local_dir) + filenames = [os.path.join(local_dir, f.name) for f in files] + return tf.data.TFRecordDataset( + filenames=filenames, + compression_type=metadata.compression_type, + buffer_size=metadata.buffer_size, + num_parallel_reads=metadata.num_parallel_reads, + name=metadata.name, + ) + + def guess_python_type(self, literal_type: LiteralType) -> Type[TFRecordsDirectory]: + 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 TFRecordsDirectory + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(TensorFlowRecordsDirTransformer()) +TypeEngine.register(TensorFlowRecordFileTransformer()) diff --git a/flytekit/flytekit/image_spec/__init__.py b/flytekit/flytekit/image_spec/__init__.py new file mode 100644 index 0000000000..ca1bdedee6 --- /dev/null +++ b/flytekit/flytekit/image_spec/__init__.py @@ -0,0 +1 @@ +from .image_spec import ImageSpec diff --git a/flytekit/flytekit/image_spec/image_spec.py b/flytekit/flytekit/image_spec/image_spec.py new file mode 100644 index 0000000000..7a8ef547da --- /dev/null +++ b/flytekit/flytekit/image_spec/image_spec.py @@ -0,0 +1,279 @@ +import base64 +import copy +import hashlib +import os +import pathlib +import typing +from abc import abstractmethod +from dataclasses import asdict, dataclass +from functools import lru_cache +from importlib import metadata +from typing import Dict, List, Optional, Tuple, Union + +import click +import requests +from packaging.version import Version + +from flytekit.exceptions.user import FlyteAssertion + +DOCKER_HUB = "docker.io" +_F_IMG_ID = "_F_IMG_ID" + + +@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. + conda_packages: list of conda packages to install. + conda_channels: list of conda channels. + requirements: path to the requirements.txt file. + apt_packages: list of apt packages to install. + cuda: version of cuda to install. + cudnn: version of cudnn to install. + base_image: base image of the image. + platform: Specify the target platforms for the build output (for example, windows/amd64 or linux/amd64,darwin/arm64 + pip_index: Specify the custom pip index url + registry_config: Specify the path to a JSON registry config file + commands: Command to run during the building process + """ + + name: str = "flytekit" + python_version: str = None # Use default python in the base image if None. + builder: Optional[str] = None + source_root: Optional[str] = None + env: Optional[typing.Dict[str, str]] = None + registry: Optional[str] = None + packages: Optional[List[str]] = None + conda_packages: Optional[List[str]] = None + conda_channels: Optional[List[str]] = None + requirements: Optional[str] = None + apt_packages: Optional[List[str]] = None + cuda: Optional[str] = None + cudnn: Optional[str] = None + base_image: Optional[str] = None + platform: str = "linux/amd64" + pip_index: Optional[str] = None + registry_config: Optional[str] = None + commands: Optional[List[str]] = None + + def __post_init__(self): + self.name = self.name.lower() + if self.registry: + self.registry = self.registry.lower() + + def image_name(self) -> str: + """Full image name with tag.""" + image_name = self._image_name() + try: + return ImageBuildEngine._IMAGE_NAME_TO_REAL_NAME[image_name] + except KeyError: + return image_name + + def _image_name(self) -> str: + """Construct 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 + 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 self.registry and "/" 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(asdict(self).__str__()) + + def with_commands(self, commands: Union[str, List[str]]) -> "ImageSpec": + """ + Builder that returns a new image spec with additional list of commands that will be executed during the building process. + """ + new_image_spec = copy.deepcopy(self) + if new_image_spec.commands is None: + new_image_spec.commands = [] + + if isinstance(commands, List): + new_image_spec.commands.extend(commands) + else: + new_image_spec.commands.append(commands) + + return new_image_spec + + def with_packages(self, packages: Union[str, List[str]]) -> "ImageSpec": + """ + Builder that returns a new image speck with additional python packages that will be installed during the building process. + """ + new_image_spec = copy.deepcopy(self) + if new_image_spec.packages is None: + new_image_spec.packages = [] + + if isinstance(packages, List): + new_image_spec.packages.extend(packages) + else: + new_image_spec.packages.append(packages) + + return new_image_spec + + def with_apt_packages(self, apt_packages: Union[str, List[str]]) -> "ImageSpec": + """ + Builder that returns a new image spec with additional list of apt packages that will be executed during the building process. + """ + new_image_spec = copy.deepcopy(self) + if new_image_spec.apt_packages is None: + new_image_spec.apt_packages = [] + + if isinstance(apt_packages, List): + new_image_spec.apt_packages.extend(apt_packages) + else: + new_image_spec.apt_packages.append(apt_packages) + + return new_image_spec + + +class ImageSpecBuilder: + @abstractmethod + def build_image(self, image_spec: ImageSpec) -> Optional[str]: + """ + Build the docker image and push it to the registry. + + Args: + image_spec: image spec of the task. + + Returns: + fully_qualified_image_name: Fully qualified image name. If None, then `image_spec.image_name()` is used. + """ + 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, Tuple[ImageSpecBuilder, int]] = {} + _BUILT_IMAGES: typing.Set[str] = set() + # _IMAGE_NAME_TO_REAL_NAME is used to keep track of the fully qualified image name + # returned by the image builder. This allows ImageSpec to map from `image_spc.image_name()` + # to the real qualified name. + _IMAGE_NAME_TO_REAL_NAME: Dict[str, str] = {} + + @classmethod + def register(cls, builder_type: str, image_spec_builder: ImageSpecBuilder, priority: int = 5): + cls._REGISTRY[builder_type] = (image_spec_builder, priority) + + @classmethod + @lru_cache + def build(cls, image_spec: ImageSpec) -> str: + if image_spec.builder is None and cls._REGISTRY: + builder = max(cls._REGISTRY, key=lambda name: cls._REGISTRY[name][1]) + else: + builder = image_spec.builder + + img_name = image_spec.image_name() + if img_name in cls._BUILT_IMAGES or image_spec.exist(): + click.secho(f"Image {img_name} found. Skip building.", fg="blue") + else: + click.secho(f"Image {img_name} not found. Building...", fg="blue") + if builder not in cls._REGISTRY: + raise Exception(f"Builder {builder} is not registered.") + if builder == "envd": + envd_version = metadata.version("envd") + # flytekit v1.10.2+ copies the workflow code to the WorkDir specified in the Dockerfile. However, envd<0.3.39 + # overwrites the WorkDir when building the image, resulting in a permission issue when flytekit downloads the file. + if Version(envd_version) < Version("0.3.39"): + raise FlyteAssertion( + f"envd version {envd_version} is not compatible with flytekit>v1.10.2." + f" Please upgrade envd to v0.3.39+." + ) + + fully_qualified_image_name = cls._REGISTRY[builder][0].build_image(image_spec) + if fully_qualified_image_name is not None: + cls._IMAGE_NAME_TO_REAL_NAME[img_name] = fully_qualified_image_name + cls._BUILT_IMAGES.add(img_name) + + +@lru_cache +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.deepcopy(image_spec) + spec.source_root = hash_directory(image_spec.source_root) if image_spec.source_root else b"" + if spec.requirements: + spec.requirements = hashlib.sha1(pathlib.Path(spec.requirements).read_bytes()).hexdigest() + # won't rebuild the image if we change the registry_config path + spec.registry_config = None + image_spec_bytes = asdict(spec).__str__().encode("utf-8") + tag = base64.urlsafe_b64encode(hashlib.md5(image_spec_bytes).digest()).decode("ascii") + # replace "=" with "." and replace "-" with "_" to make it a valid tag + return tag.replace("=", ".").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/flytekit/interaction/__init__.py b/flytekit/flytekit/interaction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/interaction/click_types.py b/flytekit/flytekit/interaction/click_types.py new file mode 100644 index 0000000000..4bdcfad1fc --- /dev/null +++ b/flytekit/flytekit/interaction/click_types.py @@ -0,0 +1,369 @@ +import datetime +import enum +import json +import logging +import os +import pathlib +import typing +from typing import cast + +import cloudpickle +import rich_click as click +import yaml +from dataclasses_json import DataClassJsonMixin +from pytimeparse import parse + +from flytekit import BlobType, FlyteContext, FlyteContextManager, Literal, LiteralType, StructuredDataset +from flytekit.core.data_persistence import FileAccessProvider +from flytekit.core.type_engine import TypeEngine +from flytekit.models.types import SimpleType +from flytekit.remote.remote_fs import FlytePathResolver +from flytekit.types.directory import FlyteDirectory +from flytekit.types.file import FlyteFile +from flytekit.types.pickle.pickle import FlytePickleTransformer + + +def is_pydantic_basemodel(python_type: typing.Type) -> bool: + """ + Checks if the python type is a pydantic BaseModel + """ + try: + import pydantic + except ImportError: + return False + else: + return issubclass(python_type, pydantic.BaseModel) + + +def key_value_callback(_: typing.Any, param: str, values: typing.List[str]) -> typing.Optional[typing.Dict[str, str]]: + """ + Callback for click to parse key-value pairs. + """ + if not values: + return None + result = {} + for v in values: + if "=" not in v: + raise click.BadParameter(f"Expected key-value pair of the form key=value, got {v}") + k, v = v.split("=", 1) + result[k.strip()] = v.strip() + return result + + +class DirParamType(click.ParamType): + name = "directory path" + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + p = pathlib.Path(value) + # set remote_directory to false if running pyflyte run locally. This makes sure that the original + # directory is used and not a random one. + remote_directory = None if getattr(ctx.obj, "is_remote", False) else False + if p.exists() and p.is_dir(): + return FlyteDirectory(path=value, remote_directory=remote_directory) + raise click.BadParameter(f"parameter should be a valid directory path, {value}") + + +class StructuredDatasetParamType(click.ParamType): + """ + TODO handle column types + """ + + name = "structured dataset path (dir/file)" + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + if isinstance(value, str): + return StructuredDataset(uri=value) + elif isinstance(value, StructuredDataset): + return value + return StructuredDataset(dataframe=value) + + +class FileParamType(click.ParamType): + name = "file path" + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + # set remote_directory to false if running pyflyte run locally. This makes sure that the original + # file is used and not a random one. + remote_path = None if getattr(ctx.obj, "is_remote", False) else False + if not FileAccessProvider.is_remote(value): + p = pathlib.Path(value) + if not p.exists() or not p.is_file(): + raise click.BadParameter(f"parameter should be a valid file path, {value}") + return FlyteFile(path=value, remote_path=remote_path) + + +class PickleParamType(click.ParamType): + name = "pickle" + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + # set remote_directory to false if running pyflyte run locally. This makes sure that the original + # file is used and not a random one. + remote_path = None if getattr(ctx.obj, "is_remote", None) else False + if os.path.isfile(value): + return FlyteFile(path=value, remote_path=remote_path) + uri = FlyteContextManager.current_context().file_access.get_random_local_path() + with open(uri, "w+b") as outfile: + cloudpickle.dump(value, outfile) + return FlyteFile(path=str(pathlib.Path(uri).resolve()), remote_path=remote_path) + + +class DateTimeType(click.DateTime): + _NOW_FMT = "now" + _ADDITONAL_FORMATS = [_NOW_FMT] + + def __init__(self): + super().__init__() + self.formats.extend(self._ADDITONAL_FORMATS) + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + if value in self._ADDITONAL_FORMATS: + if value == self._NOW_FMT: + return datetime.datetime.now() + return super().convert(value, param, ctx) + + +class DurationParamType(click.ParamType): + name = "[1:24 | :22 | 1 minute | 10 days | ...]" + + 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 Duration type.") + return datetime.timedelta(seconds=parse(value)) + + +class EnumParamType(click.Choice): + def __init__(self, enum_type: typing.Type[enum.Enum]): + super().__init__([str(e.value) for e in enum_type]) + self._enum_type = enum_type + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> enum.Enum: + if isinstance(value, self._enum_type): + return value + return self._enum_type(super().convert(value, param, ctx)) + + +class UnionParamType(click.ParamType): + """ + A composite type that allows for multiple types to be specified. This is used for union types. + """ + + def __init__(self, types: typing.List[click.ParamType]): + super().__init__() + self._types = self._sort_precedence(types) + + @staticmethod + def _sort_precedence(tp: typing.List[click.ParamType]) -> typing.List[click.ParamType]: + unprocessed = [] + str_types = [] + others = [] + for t in tp: + if isinstance(t, type(click.UNPROCESSED)): + unprocessed.append(t) + elif isinstance(t, type(click.STRING)): + str_types.append(t) + else: + others.append(t) + return others + str_types + unprocessed + + def convert( + self, value: typing.Any, param: typing.Optional[click.Parameter], ctx: typing.Optional[click.Context] + ) -> typing.Any: + """ + Important to implement NoneType / Optional. + Also could we just determine the click types from the python types + """ + for t in self._types: + try: + return t.convert(value, param, ctx) + except Exception as e: + logging.debug(f"Ignoring conversion error for type {t} trying other variants in Union. Error: {e}") + raise click.BadParameter(f"Failed to convert {value} to any of the types {self._types}") + + +class JsonParamType(click.ParamType): + name = "json object OR json/yaml file path" + + def __init__(self, python_type: typing.Type): + super().__init__() + self._python_type = python_type + + def _parse(self, value: typing.Any, param: typing.Optional[click.Parameter]): + 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}") + + 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.") + + parsed_value = self._parse(value, param) + + # We compare the origin type because the json parsed value for list or dict is always a list or dict without + # the covariant type information. + if type(parsed_value) == typing.get_origin(self._python_type) or type(parsed_value) == self._python_type: + return parsed_value + + if is_pydantic_basemodel(self._python_type): + return self._python_type.parse_raw(json.dumps(parsed_value)) # type: ignore + return cast(DataClassJsonMixin, self._python_type).from_json(json.dumps(parsed_value)) + + +def modify_literal_uris(lit: Literal): + """ + Modifies the literal object recursively to replace the URIs with the native paths. + """ + if lit.collection: + for l in lit.collection.literals: + modify_literal_uris(l) + elif lit.map: + for k, v in lit.map.literals.items(): + modify_literal_uris(v) + elif lit.scalar: + if lit.scalar.blob and lit.scalar.blob.uri and lit.scalar.blob.uri.startswith(FlytePathResolver.protocol): + lit.scalar.blob._uri = FlytePathResolver.resolve_remote_path(lit.scalar.blob.uri) + elif lit.scalar.union: + modify_literal_uris(lit.scalar.union.value) + elif ( + lit.scalar.structured_dataset + and lit.scalar.structured_dataset.uri + and lit.scalar.structured_dataset.uri.startswith(FlytePathResolver.protocol) + ): + lit.scalar.structured_dataset._uri = FlytePathResolver.resolve_remote_path( + lit.scalar.structured_dataset.uri + ) + + +SIMPLE_TYPE_CONVERTER: typing.Dict[SimpleType, click.ParamType] = { + SimpleType.FLOAT: click.FLOAT, + SimpleType.INTEGER: click.INT, + SimpleType.STRING: click.STRING, + SimpleType.BOOLEAN: click.BOOL, + SimpleType.DURATION: DurationParamType(), + SimpleType.DATETIME: click.DateTime(), +} + + +def literal_type_to_click_type(lt: LiteralType, python_type: typing.Type) -> click.ParamType: + """ + Converts a Flyte LiteralType given a python_type to a click.ParamType + """ + if lt.simple: + if lt.simple == SimpleType.STRUCT: + ct = JsonParamType(python_type) + ct.name = f"JSON object {python_type.__name__}" + return ct + if lt.simple in SIMPLE_TYPE_CONVERTER: + return SIMPLE_TYPE_CONVERTER[lt.simple] + raise NotImplementedError(f"Type {lt.simple} is not supported in pyflyte run") + + if lt.enum_type: + return EnumParamType(python_type) # type: ignore + + if lt.structured_dataset_type: + return StructuredDatasetParamType() + + if lt.collection_type or lt.map_value_type: + ct = JsonParamType(python_type) + if lt.collection_type: + ct.name = "json list" + else: + ct.name = "json dictionary" + return ct + + if lt.blob: + if lt.blob.dimensionality == BlobType.BlobDimensionality.SINGLE: + if lt.blob.format == FlytePickleTransformer.PYTHON_PICKLE_FORMAT: + return PickleParamType() + return FileParamType() + return DirParamType() + + if lt.union_type: + cts = [] + for i in range(len(lt.union_type.variants)): + variant = lt.union_type.variants[i] + variant_python_type = typing.get_args(python_type)[i] + ct = literal_type_to_click_type(variant, variant_python_type) + cts.append(ct) + return UnionParamType(cts) + + return click.UNPROCESSED + + +class FlyteLiteralConverter(object): + name = "literal_type" + + def __init__( + self, + flyte_ctx: FlyteContext, + literal_type: LiteralType, + python_type: typing.Type, + is_remote: bool, + ): + self._is_remote = is_remote + self._literal_type = literal_type + self._python_type = python_type + self._flyte_ctx = flyte_ctx + self._click_type = literal_type_to_click_type(literal_type, python_type) + + @property + def click_type(self) -> click.ParamType: + return self._click_type + + def is_bool(self) -> bool: + return self.click_type == click.BOOL + + def convert( + self, ctx: click.Context, param: typing.Optional[click.Parameter], value: typing.Any + ) -> typing.Union[Literal, typing.Any]: + """ + Convert the value to a Flyte Literal or a python native type. This is used by click to convert the input. + """ + try: + # If the expected Python type is datetime.date, adjust the value to date + if self._python_type is datetime.date: + # Click produces datetime, so converting to date to avoid type mismatch error + value = value.date() + lit = TypeEngine.to_literal(self._flyte_ctx, value, self._python_type, self._literal_type) + + if not self._is_remote: + # If this is used for remote execution then we need to convert it back to a python native type + # for FlyteRemote to use it. This maybe a double conversion penalty! + 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 if param else 'NA'}, value: {value} to type: {self._python_type}." + f" Reason {e}" + ) from e diff --git a/flytekit/flytekit/interaction/parse_stdin.py b/flytekit/flytekit/interaction/parse_stdin.py new file mode 100644 index 0000000000..98291203e6 --- /dev/null +++ b/flytekit/flytekit/interaction/parse_stdin.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import typing + +import click + +from flytekit.core.context_manager import FlyteContext +from flytekit.core.type_engine import TypeEngine +from flytekit.models.literals import Literal +from flytekit.models.types import LiteralType + + +def parse_stdin_to_literal( + ctx: FlyteContext, t: typing.Type, message: typing.Optional[str], lt: typing.Optional[LiteralType] = None +) -> Literal: + """ + Parses the user input from stdin and converts it to a literal of the given type. + """ + from flytekit.interaction.click_types import FlyteLiteralConverter + + if not lt: + lt = TypeEngine.to_literal_type(t) + literal_converter = FlyteLiteralConverter( + ctx, + literal_type=lt, + python_type=t, + is_remote=False, + ) + user_input = click.prompt(message, type=literal_converter.click_type) + try: + option = click.Option(["--input"], type=literal_converter.click_type) + v = literal_converter.click_type.convert(user_input, option, click.Context(command=click.Command(""))) + return TypeEngine.to_literal(FlyteContext.current_context(), v, t, lt) + except Exception as e: + raise click.ClickException(f"Failed to parse input: {e}") diff --git a/flytekit/flytekit/interaction/rich_utils.py b/flytekit/flytekit/interaction/rich_utils.py new file mode 100644 index 0000000000..adc7095b52 --- /dev/null +++ b/flytekit/flytekit/interaction/rich_utils.py @@ -0,0 +1,22 @@ +import typing + +from fsspec import Callback +from rich.progress import Progress + + +class RichCallback(Callback): + def __init__(self, rich_kwargs: typing.Optional[typing.Dict] = None, **kwargs): + super().__init__(**kwargs) + rich_kwargs = rich_kwargs or {} + self._pb = Progress(**rich_kwargs) + self._pb.start() + self._task = None + + def set_size(self, size): + self._task = self._pb.add_task("Downloading...", total=size) + + def relative_update(self, inc=1): + self._pb.update(self._task, advance=inc) + + def __del__(self): + self._pb.stop() diff --git a/flytekit/flytekit/interaction/string_literals.py b/flytekit/flytekit/interaction/string_literals.py new file mode 100644 index 0000000000..8bc9421334 --- /dev/null +++ b/flytekit/flytekit/interaction/string_literals.py @@ -0,0 +1,72 @@ +import base64 +import typing + +from google.protobuf.json_format import MessageToDict + +from flytekit.models.literals import Literal, LiteralMap, Primitive, Scalar + + +def primitive_to_string(primitive: Primitive) -> typing.Any: + """ + This method is used to convert a primitive to a string representation. + """ + if primitive.integer is not None: + return primitive.integer + if primitive.float_value is not None: + return primitive.float_value + if primitive.boolean is not None: + return primitive.boolean + if primitive.string_value is not None: + return primitive.string_value + if primitive.datetime is not None: + return primitive.datetime.isoformat() + if primitive.duration is not None: + return primitive.duration.total_seconds() + raise ValueError(f"Unknown primitive type {primitive}") + + +def scalar_to_string(scalar: Scalar) -> typing.Any: + """ + This method is used to convert a scalar to a string representation. + """ + if scalar.primitive: + return primitive_to_string(scalar.primitive) + if scalar.none_type: + return None + if scalar.error: + return scalar.error.message + if scalar.structured_dataset: + return scalar.structured_dataset.uri + if scalar.blob: + return scalar.blob.uri + if scalar.binary: + return base64.b64encode(scalar.binary.value) + if scalar.generic: + return MessageToDict(scalar.generic) + if scalar.union: + return literal_string_repr(scalar.union.value) + raise ValueError(f"Unknown scalar type {scalar}") + + +def literal_string_repr(lit: Literal) -> typing.Any: + """ + This method is used to convert a literal to a string representation. This is useful in places, where we need to + use a shortened string representation of a literal, especially a FlyteFile, FlyteDirectory, or StructuredDataset. + """ + if lit.scalar: + return scalar_to_string(lit.scalar) + if lit.collection: + return [literal_string_repr(i) for i in lit.collection.literals] + if lit.map: + return {k: literal_string_repr(v) for k, v in lit.map.literals.items()} + raise ValueError(f"Unknown literal type {lit}") + + +def literal_map_string_repr(lm: typing.Union[LiteralMap, typing.Dict[str, Literal]]) -> typing.Dict[str, typing.Any]: + """ + This method is used to convert a literal map to a string representation. + """ + lmd = lm + if isinstance(lm, LiteralMap): + lmd = lm.literals + return {k: literal_string_repr(v) for k, v in lmd.items()} diff --git a/flytekit/flytekit/interfaces/__init__.py b/flytekit/flytekit/interfaces/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/interfaces/cli_identifiers.py b/flytekit/flytekit/interfaces/cli_identifiers.py new file mode 100644 index 0000000000..d233c2cf3a --- /dev/null +++ b/flytekit/flytekit/interfaces/cli_identifiers.py @@ -0,0 +1,180 @@ +from flytekit.exceptions import user as _user_exceptions +from flytekit.models.core import identifier as _core_identifier + + +class Identifier(_core_identifier.Identifier): + _STRING_TO_TYPE_MAP = { + "lp": _core_identifier.ResourceType.LAUNCH_PLAN, + "wf": _core_identifier.ResourceType.WORKFLOW, + "tsk": _core_identifier.ResourceType.TASK, + } + _TYPE_TO_STRING_MAP = {v: k for k, v in _STRING_TO_TYPE_MAP.items()} + + @classmethod + def promote_from_model(cls, base_model): + """ + :param flytekit.models.core.identifier.Identifier base_model: + :rtype: Identifier + """ + return cls( + base_model.resource_type, + base_model.project, + base_model.domain, + base_model.name, + base_model.version, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + base_model = super().from_flyte_idl(pb2_object) + return cls.promote_from_model(base_model) + + @classmethod + def from_python_std(cls, string): + """ + Parses a string in the correct format into an identifier + :param Text string: + :rtype: Identifier + """ + segments = string.split(":") + if len(segments) != 5: + raise _user_exceptions.FlyteValueException( + "The provided string was not in a parseable format. The string for an identifier must be in the format" + " entity_type:project:domain:name:version. Received: {}".format(string) + ) + + resource_type, project, domain, name, version = segments + + if resource_type not in cls._STRING_TO_TYPE_MAP: + raise _user_exceptions.FlyteValueException( + resource_type, + "The provided string could not be parsed. The first element of an identifier must be one of: {}. " + "Received: {}".format(list(cls._STRING_TO_TYPE_MAP.keys()), resource_type), + ) + resource_type = cls._STRING_TO_TYPE_MAP[resource_type] + + return cls(resource_type, project, domain, name, version) + + def __str__(self): + return "{}:{}:{}:{}:{}".format( + type(self)._TYPE_TO_STRING_MAP.get(self.resource_type, ""), + self.project, + self.domain, + self.name, + self.version, + ) + + +class TaskExecutionIdentifier(_core_identifier.TaskExecutionIdentifier): + @classmethod + def promote_from_model(cls, base_model): + """ + :param flytekit.models.core.identifier.TaskExecutionIdentifier base_model: + :rtype: TaskExecutionIdentifier + """ + return cls( + task_id=base_model.task_id, + node_execution_id=base_model.node_execution_id, + retry_attempt=base_model.retry_attempt, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + base_model = super().from_flyte_idl(pb2_object) + return cls.promote_from_model(base_model) + + @classmethod + def from_python_std(cls, string): + """ + Parses a string in the correct format into an identifier + :param Text string: + :rtype: TaskExecutionIdentifier + """ + segments = string.split(":") + if len(segments) != 10: + raise _user_exceptions.FlyteValueException( + string, + "The provided string was not in a parseable format. The string for an identifier must be in the format" + " te:exec_project:exec_domain:exec_name:node_id:task_project:task_domain:task_name:task_version:retry.", + ) + + resource_type, ep, ed, en, node_id, tp, td, tn, tv, retry = segments + + if resource_type != "te": + raise _user_exceptions.FlyteValueException( + resource_type, + "The provided string could not be parsed. The first element of an execution identifier must be 'ex'.", + ) + + return cls( + task_id=Identifier(_core_identifier.ResourceType.TASK, tp, td, tn, tv), + node_execution_id=_core_identifier.NodeExecutionIdentifier( + node_id=node_id, + execution_id=_core_identifier.WorkflowExecutionIdentifier(ep, ed, en), + ), + retry_attempt=int(retry), + ) + + def __str__(self): + return "te:{ep}:{ed}:{en}:{node_id}:{tp}:{td}:{tn}:{tv}:{retry}".format( + ep=self.node_execution_id.execution_id.project, + ed=self.node_execution_id.execution_id.domain, + en=self.node_execution_id.execution_id.name, + node_id=self.node_execution_id.node_id, + tp=self.task_id.project, + td=self.task_id.domain, + tn=self.task_id.name, + tv=self.task_id.version, + retry=self.retry_attempt, + ) + + +class WorkflowExecutionIdentifier(_core_identifier.WorkflowExecutionIdentifier): + @classmethod + def promote_from_model(cls, base_model): + """ + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier base_model: + :rtype: WorkflowExecutionIdentifier + """ + return cls( + base_model.project, + base_model.domain, + base_model.name, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + base_model = super().from_flyte_idl(pb2_object) + return cls.promote_from_model(base_model) + + @classmethod + def from_python_std(cls, string): + """ + Parses a string in the correct format into an identifier + :param Text string: + :rtype: WorkflowExecutionIdentifier + """ + segments = string.split(":") + if len(segments) != 4: + raise _user_exceptions.FlyteValueException( + string, + "The provided string was not in a parseable format. The string for an identifier must be in the format" + " ex:project:domain:name.", + ) + + resource_type, project, domain, name = segments + + if resource_type != "ex": + raise _user_exceptions.FlyteValueException( + resource_type, + "The provided string could not be parsed. The first element of an execution identifier must be 'ex'.", + ) + + return cls( + project, + domain, + name, + ) + + def __str__(self): + return "ex:{}:{}:{}".format(self.project, self.domain, self.name) diff --git a/flytekit/flytekit/interfaces/random.py b/flytekit/flytekit/interfaces/random.py new file mode 100644 index 0000000000..aac57b2fb2 --- /dev/null +++ b/flytekit/flytekit/interfaces/random.py @@ -0,0 +1,23 @@ +import random as _random + +random = _random.Random() +""" +An instance of the global random number generator used by flytekit. Flytekit maintains it's own random instance +to ensure that calls to random.seed(...) do not affect the pseudo-random behavior of flytekit. This random should be +used by flytekit components in all cases where random.random would have been used. Components who want additional +protections for their random number generator might also maintain their own separate random instance. +""" + + +def seed_flyte_random(seed): + """ + If one wants to influence the pseudo-random behavior of flytekit, this function can be used to seed the flytekit + generator. It is not recommended that this be done as lack of entropy between jobs can result in overwriting data + created at random locations. + + Currently, this is used by flytekit to create entropy in low entropy situations (such as Array Jobs) where the job + index can be used as a seed to ensure sibling jobs do not have random collisions. + :param Union[Text,int,bytes] seed: + """ + global random + random = _random.Random(seed) diff --git a/flytekit/flytekit/interfaces/stats/__init__.py b/flytekit/flytekit/interfaces/stats/__init__.py new file mode 100644 index 0000000000..22080a7bfe --- /dev/null +++ b/flytekit/flytekit/interfaces/stats/__init__.py @@ -0,0 +1,2 @@ +# This package has been developed by the following github.com users and adopted to this repo. +# @yashlyft, @tildedave, @jmphili, @pgerstoft, @johnliu, @ikonst, @mxr, @simonj2, @rowilia, @asottile, @mattdcamp diff --git a/flytekit/flytekit/interfaces/stats/client.py b/flytekit/flytekit/interfaces/stats/client.py new file mode 100644 index 0000000000..a49b482c09 --- /dev/null +++ b/flytekit/flytekit/interfaces/stats/client.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# +import re +import sys + +import statsd + +from flytekit.configuration import StatsConfig + +RESERVED_TAG_WORDS = frozenset( + ["asg", "az", "backend", "canary", "host", "period", "region", "shard", "window", "source"] +) + +# TODO should this be a whitelist instead? +FORBIDDEN_TAG_VALUE_CHARACTERS = re.compile("[|.:]") + +_stats_client = None + + +class ScopeableStatsProxy(object): + """ + A Proxy object for an underlying statsd client. + Adds a new call, scope(prefix), which returns a new proxy to the same + client which will prefix all calls to underlying methods with the scoped prefix: + new_client = client.get_stats('a') + new_client.incr('b') # Metric name = a.b + This can be nested: + newer_client = new_client.get_stats('subsystem') + newer_client.incr('bad') # Metric name = a.subsystem.bad + """ + + # List of functions we will proxy and prefix the first string argument on + EXTENDABLE_FUNC = ["incr", "decr", "timing", "timer", "gauge", "set"] + + def __init__(self, client, prefix=None): + self._client = client + self._scope_prefix = prefix + for extendable_func in self.EXTENDABLE_FUNC: + base_func = getattr(self._client, extendable_func) + if base_func: + setattr(self, extendable_func, self._create_wrapped_function(base_func)) + + def _create_wrapped_function(self, base_func): + if self._scope_prefix: + + def name_wrap(stat, *args, **kwargs): + tags = kwargs.pop("tags", {}) + if kwargs.pop("per_host", False): + tags["_f"] = "i" + + if bool(tags): + stat = self._serialize_tags(stat, tags) + return base_func(self._p_with_prefix(stat), *args, **kwargs) + + else: + + def name_wrap(stat, *args, **kwargs): + tags = kwargs.pop("tags", {}) + if kwargs.pop("per_host", False): + tags["_f"] = "i" + + if bool(tags): + stat = self._serialize_tags(stat, tags) + return base_func(stat, *args, **kwargs) + + return name_wrap + + def get_stats(self, name): + if not self._scope_prefix or self._scope_prefix == "": + prefix = name + else: + prefix = self._scope_prefix + "." + name + + return ScopeableStatsProxy(self._client, prefix) + + def pipeline(self): + return ScopeableStatsProxy(self._client.pipeline(), self._scope_prefix) + + def _p_with_prefix(self, name): + if name is None: + return name + return self._scope_prefix + "." + name + + def _is_ascii(self, name): + if sys.version_info >= (3, 7): + return name.isascii() + try: + return name and name.encode("ascii") + except UnicodeEncodeError: + return False + + def _serialize_tags(self, metric, tags=None): + stat = metric + if tags: + for key in sorted(tags): + try: + # Python 2.7, will throw UnicodeEncodeError + # Python 3.4+, will need ascii check, + # pystatsd 3.2.1 can handle ascii only (.encode with "ignore" can be lossy) + key = str(key) + # if the tags fail sanity check we will not serialize the tags, and simply return the metric. + if not self._is_ascii(key): + return stat + tag = FORBIDDEN_TAG_VALUE_CHARACTERS.sub("_", str(tags[key])) + if tag != "": + metric += ".__{0}={1}".format(key, tag) + except UnicodeEncodeError: + # TODO: log warning here and possibly fail the entire request? + return stat + + return metric + + def __hasattr__(self, name): + return hasattr(self._client, name) + + def __getattr__(self, name): + return getattr(self._client, name) + + def __enter__(self): + return ScopeableStatsProxy(self._client.__enter__(), self._scope_prefix) + + def __exit__(self, exc_type, exc_value, traceback): + self._client.__exit__(exc_type, exc_value, traceback) + + +class StatsClientProxy(ScopeableStatsProxy): + @property + def _prefix(self): + return self._scope_prefix + + +def _get_stats_client(cfg: StatsConfig): + global _stats_client + if cfg.disabled is True: + _stats_client = DummyStatsClient() + if _stats_client is None: + _stats_client = statsd.StatsClient(cfg.host, cfg.port) + return _stats_client + + +def get_base_stats(cfg: StatsConfig, prefix: str): + return StatsClientProxy(_get_stats_client(cfg), prefix=prefix) + + +def get_stats(cfg: StatsConfig, prefix: str): + return get_base_stats(cfg, prefix) + + +class DummyStatsClient(statsd.StatsClient): + """A dummy client for statsd.""" + + def __init__(self, host="localhost", port=8125, prefix=None, maxudpsize=512, ipv6=False): + super().__init__(host, port, prefix, maxudpsize, ipv6) + + def _send(self, data): + pass diff --git a/flytekit/flytekit/interfaces/stats/taggable.py b/flytekit/flytekit/interfaces/stats/taggable.py new file mode 100644 index 0000000000..19e7b35c10 --- /dev/null +++ b/flytekit/flytekit/interfaces/stats/taggable.py @@ -0,0 +1,96 @@ +from typing import Dict + +from flytekit.configuration import StatsConfig +from flytekit.interfaces.stats import client as _stats_client + + +class TaggableStats(_stats_client.ScopeableStatsProxy): + # List of functions we will proxy and prefix the first string argument on + EXTENDABLE_FUNC = ["incr", "decr", "timing", "timer", "gauge", "set"] + + def __init__(self, client, full_prefix, cfg: StatsConfig, prefix=None, tags=None): + super(TaggableStats, self).__init__(client, prefix=prefix) + self._tags = tags if tags else {} + self._full_prefix = full_prefix + self._cfg = cfg + + def _create_wrapped_function(self, base_func): + if self._scope_prefix: + + def name_wrap(stat, *args, **kwargs): + tags = kwargs.pop("tags", {}) + tags.update(self._tags) + if kwargs.pop("per_host", False): + tags["_f"] = "i" + + if bool(tags) and not self._cfg.disabled_tags: + stat = self._serialize_tags(stat, tags) + return base_func(self._p_with_prefix(stat), *args, **kwargs) + + else: + + def name_wrap(stat, *args, **kwargs): + tags = kwargs.pop("tags", {}) + tags.update(self._tags) + if kwargs.pop("per_host", False): + tags["_f"] = "i" + + if bool(tags) and not self._cfg.disabled_tags: + stat = self._serialize_tags(stat, tags) + return base_func(stat, *args, **kwargs) + + return name_wrap + + def clear_tags(self): + self._tags = {} + + def extend_tags(self, tags): + self._tags.update(tags) + + def pipeline(self): + return TaggableStats( + self._client.pipeline(), + self._full_prefix, + cfg=self._cfg, + prefix=self._scope_prefix, + tags=dict(self._tags), + ) + + def __enter__(self): + return TaggableStats( + self._client.__enter__(), + self._full_prefix, + cfg=self._cfg, + prefix=self._scope_prefix, + tags=dict(self._tags), + ) + + def get_stats(self, name, copy_tags=True): + if not self._scope_prefix or self._scope_prefix == "": + prefix = name + else: + prefix = self._scope_prefix + "." + name + + if self._full_prefix: + full_prefix = self._full_prefix + "." + prefix + else: + full_prefix = prefix + + tags = dict(self._tags) if copy_tags else None + return TaggableStats(self._client, full_prefix, prefix=prefix, tags=tags) + + @property + def full_prefix(self): + return self._full_prefix + + +def get_stats(cfg: StatsConfig, prefix: str, tags: Dict[str, str] = None) -> TaggableStats: + """ + :rtype: TaggableStats + """ + + # If tagging is disabled, do not pass tags to the constructor. + if cfg.disabled_tags: + tags = None + + return TaggableStats(_stats_client.get_base_stats(cfg, prefix.lower()), prefix.lower(), cfg=cfg, tags=tags) diff --git a/flytekit/flytekit/lazy_import/__init__.py b/flytekit/flytekit/lazy_import/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/lazy_import/lazy_module.py b/flytekit/flytekit/lazy_import/lazy_module.py new file mode 100644 index 0000000000..58f9923ff2 --- /dev/null +++ b/flytekit/flytekit/lazy_import/lazy_module.py @@ -0,0 +1,47 @@ +import importlib.util +import sys +import types + +LAZY_MODULES = [] + + +class LazyModule(types.ModuleType): + def __init__(self, module_name: str): + super().__init__(module_name) + self._module_name = module_name + + def __getattribute__(self, attr): + raise ImportError(f"Module {object.__getattribute__(self, '_module_name')} is not yet installed.") + + +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) + if spec is None: + # Return a lazy module if the module is not found in the python environment, + # so that we can raise a proper error when the user tries to access an attribute in the module. + return LazyModule(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/flytekit/loggers.py b/flytekit/flytekit/loggers.py new file mode 100644 index 0000000000..4d8bd6a5e0 --- /dev/null +++ b/flytekit/flytekit/loggers.py @@ -0,0 +1,146 @@ +import logging +import os +import typing + +from pythonjsonlogger import jsonlogger + +from .tools import interactive + +# Note: +# The environment variable controls exposed to affect the individual loggers should be considered to be beta. +# The ux/api may change in the future. +# At time of writing, the code was written to preserve existing default behavior +# 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" +LOGGING_RICH_FMT_ENV_VAR = "FLYTE_SDK_RICH_TRACEBACKS" + +# By default, the root flytekit logger to debug so everything is logged, but enable fine-tuning +logger = logging.getLogger("flytekit") +user_space_logger = logging.getLogger("user_space") + +# Stop propagation so that configuration is isolated to this file (so that it doesn't matter what the +# global Python root logger is set to). +logger.propagate = False + + +def set_flytekit_log_properties( + handler: typing.Optional[logging.Handler] = None, + filter: typing.Optional[logging.Filter] = None, + level: typing.Optional[int] = None, +): + """ + flytekit logger, refers to the framework logger. It is possible to selectively tune the logging for flytekit. + + Sets the flytekit logger to the specified handler, filter, and level. If any of the parameters are None, then + the corresponding property on the flytekit logger will not be set. + + :param handler: logging.Handler to add to the flytekit logger + :param filter: logging.Filter to add to the flytekit logger + :param level: logging level to set the flytekit logger to + """ + global logger + if handler is not None: + logger.handlers.clear() + logger.addHandler(handler) + if filter is not None: + logger.addFilter(filter) + if level is not None: + logger.setLevel(level) + + +def set_user_logger_properties( + handler: typing.Optional[logging.Handler] = None, + filter: typing.Optional[logging.Filter] = None, + level: typing.Optional[int] = None, +): + """ + user_space logger, refers to the user's logger. It is possible to selectively tune the logging for the user. + + :param handler: logging.Handler to add to the user_space_logger + :param filter: logging.Filter to add to the user_space_logger + :param level: logging level to set the user_space_logger to + """ + global user_space_logger + if handler is not None: + user_space_logger.addHandler(handler) + if filter is not None: + user_space_logger.addFilter(filter) + if level is not None: + user_space_logger.setLevel(level) + + +def _get_env_logging_level() -> int: + """ + Returns the logging level set in the environment variable, or logging.WARNING if the environment variable is not + set. + """ + return int(os.getenv(LOGGING_ENV_VAR, logging.WARNING)) + + +def initialize_global_loggers(): + """ + Initializes the global loggers to the default configuration. + """ + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter(fmt="[%(name)s] %(message)s") + if os.environ.get(LOGGING_FMT_ENV_VAR, "json") == "json": + formatter = jsonlogger.JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s") + handler.setFormatter(formatter) + + set_flytekit_log_properties(handler, None, _get_env_logging_level()) + set_user_logger_properties(handler, None, logging.INFO) + + +def upgrade_to_rich_logging( + console: typing.Optional["rich.console.Console"] = None, log_level: typing.Optional[int] = None +): + formatter = logging.Formatter(fmt="%(message)s") + handler = logging.StreamHandler() + if os.environ.get(LOGGING_RICH_FMT_ENV_VAR) != "0": + try: + import click + from rich.console import Console + from rich.logging import RichHandler + + import flytekit + + handler = RichHandler( + tracebacks_suppress=[click, flytekit], + rich_tracebacks=True, + omit_repeated_times=False, + log_time_format="%H:%M:%S.%f", + console=Console(width=os.get_terminal_size().columns), + ) + except OSError as e: + logger.debug(f"Failed to initialize rich logging: {e}") + pass + handler.setFormatter(formatter) + set_flytekit_log_properties(handler, None, level=log_level or _get_env_logging_level()) + set_user_logger_properties(handler, None, logging.INFO) + + +def get_level_from_cli_verbosity(verbosity: int) -> int: + """ + Converts a verbosity level from the CLI to a logging level. + + :param verbosity: verbosity level from the CLI + :return: logging level + """ + if verbosity == 0: + return logging.CRITICAL + elif verbosity == 1: + return logging.WARNING + elif verbosity == 2: + return logging.INFO + else: + return logging.DEBUG + + +if interactive.ipython_check(): + upgrade_to_rich_logging() +else: + # Default initialization + initialize_global_loggers() diff --git a/flytekit/flytekit/models/__init__.py b/flytekit/flytekit/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/models/admin/__init__.py b/flytekit/flytekit/models/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/models/admin/common.py b/flytekit/flytekit/models/admin/common.py new file mode 100644 index 0000000000..3baba54e24 --- /dev/null +++ b/flytekit/flytekit/models/admin/common.py @@ -0,0 +1,70 @@ +from flyteidl.admin import common_pb2 as _common_pb2 + +from flytekit.models import common as _common + + +class Sort(_common.FlyteIdlEntity): + class Direction(object): + DESCENDING = _common_pb2.Sort.DESCENDING + ASCENDING = _common_pb2.Sort.ASCENDING + + def __init__(self, key, direction): + """ + :param Text key: field to sort on + :param int direction: From flytekit.models.admin.common.Sort.Direction enum + """ + self._key = key + self._direction = direction + + @property + def key(self): + """ + :rtype: Text + """ + return self._key + + @property + def direction(self): + """ + :rtype: int + """ + return self._direction + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.Sort + """ + return _common_pb2.Sort(key=self.key, direction=self.direction) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.common_pb2.Sort pb2_object: + :rtype: Sort + """ + return cls(key=pb2_object.key, direction=pb2_object.direction) + + @classmethod + def from_python_std(cls, text): + """ + :param Text text: + :rtype: Sort + """ + text = text.strip() + if text[-1] != ")": + raise ValueError( + "Could not parse string. Must be in format 'asc(key)' or 'desc(key)'. '{}' did not " + "end with ')'.".format(text) + ) + if text.startswith("asc("): + direction = Sort.Direction.ASCENDING + key = text[len("asc(") : -1].strip() + elif text.startswith("desc("): + direction = Sort.Direction.DESCENDING + key = text[len("desc(") : -1].strip() + else: + raise ValueError( + "Could not parse string. Must be in format 'asc(key)' or 'desc(key)'. '{}' did not " + "start with 'asc(' or 'desc'.".format(text) + ) + return cls(key=key, direction=direction) diff --git a/flytekit/flytekit/models/admin/task_execution.py b/flytekit/flytekit/models/admin/task_execution.py new file mode 100644 index 0000000000..d0a6d4ed2d --- /dev/null +++ b/flytekit/flytekit/models/admin/task_execution.py @@ -0,0 +1,195 @@ +from flyteidl.admin import task_execution_pb2 as _task_execution_pb2 + +from flytekit.models import common as _common +from flytekit.models.core import execution as _execution +from flytekit.models.core import identifier as _identifier + + +class TaskExecutionClosure(_common.FlyteIdlEntity): + def __init__( + self, + phase, + logs, + started_at, + duration, + created_at, + updated_at, + output_uri=None, + error=None, + ): + """ + :param int phase: Enum value from flytekit.models.core.execution.TaskExecutionPhase + :param list[flytekit.models.core.execution.TaskLog] logs: List of all logs associated with the execution. + :param datetime.datetime started_at: + :param datetime.timedelta duration: + :param datetime.datetime created_at: + :param datetime.datetime updated_at: + :param Text output_uri: If task is successful and in terminal state, this will be the path to the output + literals. + :param flytekit.models.core.execution.ExecutionError error: If task has failed and in terminal state, this will + be set to the error encountered. + """ + self._phase = phase + self._logs = logs + self._started_at = started_at + self._duration = duration + self._created_at = created_at + self._updated_at = updated_at + self._output_uri = output_uri + self._error = error + + @property + def phase(self): + """ + Enum value from flytekit.models.core.execution.TaskExecutionPhase + :rtype: int + """ + return self._phase + + @property + def logs(self): + """ + :rtype: list[flytekit.models.core.execution.TaskLog] + """ + return self._logs + + @property + def started_at(self): + """ + :rtype: datetime.datetime + """ + return self._started_at + + @property + def created_at(self): + """ + :rtype: datetime.datetime + """ + return self._created_at + + @property + def updated_at(self): + """ + :rtype: datetime.datetime + """ + return self._updated_at + + @property + def duration(self): + """ + :rtype: datetime.timedelta + """ + return self._duration + + @property + def output_uri(self): + """ + :rtype: Text + """ + return self._output_uri + + @property + def error(self): + """ + :rtype: flytekit.models.core.execution.ExecutionError + """ + return self._error + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.task_execution_pb2.TaskExecutionClosure + """ + p = _task_execution_pb2.TaskExecutionClosure( + phase=self.phase, + logs=[l.to_flyte_idl() for l in self.logs], + output_uri=self.output_uri, + error=self.error.to_flyte_idl() if self.error is not None else None, + ) + p.started_at.FromDatetime(self.started_at) + p.created_at.FromDatetime(self.created_at) + p.updated_at.FromDatetime(self.updated_at) + p.duration.FromTimedelta(self.duration) + return p + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.admin.task_execution_pb2.TaskExecutionClosure p: + :rtype: TaskExecutionClosure + """ + return cls( + phase=p.phase, + logs=[_execution.TaskLog.from_flyte_idl(l) for l in p.logs], + output_uri=p.output_uri if p.HasField("output_uri") else None, + error=_execution.ExecutionError.from_flyte_idl(p.error) if p.HasField("error") else None, + started_at=p.started_at.ToDatetime(), + created_at=p.created_at.ToDatetime(), + updated_at=p.updated_at.ToDatetime(), + duration=p.duration.ToTimedelta(), + ) + + +class TaskExecution(_common.FlyteIdlEntity): + def __init__(self, id, input_uri, closure, is_parent): + """ + :param flytekit.models.core.identifier.TaskExecutionIdentifier id: + :param Text input_uri: + :param TaskExecutionClosure closure: + :param bool is_parent: + """ + self._id = id + self._input_uri = input_uri + self._closure = closure + self._is_parent = is_parent + + @property + def id(self): + """ + :rtype: flytekit.models.core.identifier.TaskExecutionIdentifier + """ + return self._id + + @property + def input_uri(self): + """ + :rtype: Text + """ + return self._input_uri + + @property + def closure(self): + """ + :rtype: TaskExecutionClosure + """ + return self._closure + + @property + def is_parent(self): + """ + :rtype: bool + """ + return self._is_parent + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.task_execution_pb2.TaskExecution + """ + return _task_execution_pb2.TaskExecution( + id=self.id.to_flyte_idl(), + input_uri=self.input_uri, + closure=self.closure.to_flyte_idl(), + is_parent=self.is_parent, + ) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.admin.task_execution_pb2.TaskExecution proto: + :rtype: TaskExecution + """ + return cls( + id=_identifier.TaskExecutionIdentifier.from_flyte_idl(proto.id), + input_uri=proto.input_uri, + closure=TaskExecutionClosure.from_flyte_idl(proto.closure), + is_parent=proto.is_parent, + ) diff --git a/flytekit/flytekit/models/admin/workflow.py b/flytekit/flytekit/models/admin/workflow.py new file mode 100644 index 0000000000..e40307b6ba --- /dev/null +++ b/flytekit/flytekit/models/admin/workflow.py @@ -0,0 +1,139 @@ +import typing + +from flyteidl.admin import workflow_pb2 as _admin_workflow + +from flytekit.models import common as _common +from flytekit.models.core import compiler as _compiler_models +from flytekit.models.core import identifier as _identifier +from flytekit.models.core import workflow as _core_workflow +from flytekit.models.documentation import Documentation + + +class WorkflowSpec(_common.FlyteIdlEntity): + def __init__( + self, + template: _core_workflow.WorkflowTemplate, + sub_workflows: typing.List[_core_workflow.WorkflowTemplate], + docs: typing.Optional[Documentation] = None, + ): + """ + This object fully encapsulates the specification of a workflow + :param flytekit.models.core.workflow.WorkflowTemplate template: + :param list[flytekit.models.core.workflow.WorkflowTemplate] sub_workflows: + """ + self._template = template + self._sub_workflows = sub_workflows + self._docs = docs + + @property + def template(self): + """ + :rtype: flytekit.models.core.workflow.WorkflowTemplate + """ + return self._template + + @property + def sub_workflows(self): + """ + :rtype: list[flytekit.models.core.workflow.WorkflowTemplate] + """ + return self._sub_workflows + + @property + def docs(self): + """ + :rtype: Description entity for the workflow + """ + return self._docs + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.workflow_pb2.WorkflowSpec + """ + return _admin_workflow.WorkflowSpec( + template=self._template.to_flyte_idl(), + sub_workflows=[s.to_flyte_idl() for s in self._sub_workflows], + description=self._docs.to_flyte_idl() if self._docs else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param pb2_object: flyteidl.admin.workflow_pb2.WorkflowSpec + :rtype: WorkflowSpec + """ + return cls( + _core_workflow.WorkflowTemplate.from_flyte_idl(pb2_object.template), + [_core_workflow.WorkflowTemplate.from_flyte_idl(s) for s in pb2_object.sub_workflows], + Documentation.from_flyte_idl(pb2_object.description) if pb2_object.description else None, + ) + + +class Workflow(_common.FlyteIdlEntity): + def __init__(self, id, closure): + """ + :param flytekit.models.core.identifier.Identifier id: + :param WorkflowClosure closure: + """ + self._id = id + self._closure = closure + + @property + def id(self): + """ + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._id + + @property + def closure(self): + """ + :rtype: WorkflowClosure + """ + return self._closure + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.workflow_pb2.Workflow + """ + return _admin_workflow.Workflow(id=self.id.to_flyte_idl(), closure=self.closure.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.workflow_pb2.Workflow pb2_object: + :return: Workflow + """ + return cls( + id=_identifier.Identifier.from_flyte_idl(pb2_object.id), + closure=WorkflowClosure.from_flyte_idl(pb2_object.closure), + ) + + +class WorkflowClosure(_common.FlyteIdlEntity): + def __init__(self, compiled_workflow): + """ + :param flytekit.models.core.compiler.CompiledWorkflowClosure compiled_workflow: + """ + self._compiled_workflow = compiled_workflow + + @property + def compiled_workflow(self): + """ + :rtype: flytekit.models.core.compiler.CompiledWorkflowClosure + """ + return self._compiled_workflow + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.workflow_pb2.WorkflowClosure + """ + return _admin_workflow.WorkflowClosure(compiled_workflow=self.compiled_workflow.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.admin.workflow_pb2.WorkflowClosure p: + :rtype: WorkflowClosure + """ + return cls(compiled_workflow=_compiler_models.CompiledWorkflowClosure.from_flyte_idl(p.compiled_workflow)) diff --git a/flytekit/flytekit/models/annotation.py b/flytekit/flytekit/models/annotation.py new file mode 100644 index 0000000000..bea6b1dc60 --- /dev/null +++ b/flytekit/flytekit/models/annotation.py @@ -0,0 +1,48 @@ +import json as _json +from typing import Any, Dict + +from flyteidl.core import types_pb2 as _types_pb2 +from google.protobuf import json_format as _json_format +from google.protobuf import struct_pb2 as _struct + + +class TypeAnnotation: + """Python class representation of the flyteidl TypeAnnotation message.""" + + def __init__(self, annotations: Dict[str, Any]): + self._annotations = annotations + + @property + def annotations(self) -> Dict[str, Any]: + """ + :rtype: dict[str, Any] + """ + return self._annotations + + def to_flyte_idl(self) -> _types_pb2.TypeAnnotation: + """ + :rtype: flyteidl.core.types_pb2.TypeAnnotation + """ + + if self._annotations is not None: + annotations = _json_format.Parse(_json.dumps(self.annotations), _struct.Struct()) + else: + annotations = None + + return _types_pb2.TypeAnnotation( + annotations=annotations, + ) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.types_pb2.TypeAnnotation proto: + :rtype: TypeAnnotation + """ + + return cls(annotations=_json_format.MessageToDict(proto.annotations)) + + def __eq__(self, x: object) -> bool: + if not isinstance(x, self.__class__): + return False + return self.annotations == x.annotations diff --git a/flytekit/flytekit/models/array_job.py b/flytekit/flytekit/models/array_job.py new file mode 100644 index 0000000000..2c86acdd7e --- /dev/null +++ b/flytekit/flytekit/models/array_job.py @@ -0,0 +1,108 @@ +import json as _json + +from flyteidl.plugins import array_job_pb2 as _array_job +from google.protobuf import json_format as _json_format + +from flytekit.models import common as _common + + +class ArrayJob(_common.FlyteCustomIdlEntity): + def __init__(self, parallelism=None, size=None, min_successes=None, min_success_ratio=None): + """ + Initializes a new ArrayJob. + :param int parallelism: Defines the minimum number of instances to bring up concurrently at any given point. + :param int size: Defines the number of instances to launch at most. This number should match the size of + the input if the job requires processing of all input data. This has to be a positive number. + :param int min_successes: An absolute number of the minimum number of successful completions of subtasks. As + soon as this criteria is met, the array job will be marked as successful and outputs will be computed. + :param float min_success_ratio: Determines the minimum fraction of total jobs which can complete successfully + before terminating the job and marking it successful. + """ + if min_successes and min_success_ratio: + raise ValueError("Only one of min_successes or min_success_ratio can be set") + self._parallelism = parallelism + self._size = size + self._min_successes = min_successes + self._min_success_ratio = min_success_ratio + + @property + def parallelism(self): + """ + Defines the minimum number of instances to bring up concurrently at any given point. + + :rtype: int + """ + return self._parallelism + + @property + def size(self): + """ + Defines the number of instances to launch at most. This number should match the size of the input if the job + requires processing of all input data. This has to be a positive number. + + :rtype: int + """ + return self._size + + @size.setter + def size(self, value): + self._size = value + + @property + def min_successes(self): + """ + An absolute number of the minimum number of successful completions of subtasks. As soon as this criteria is met, + the array job will be marked as successful and outputs will be computed. + + :rtype: int + """ + return self._min_successes + + @property + def min_success_ratio(self): + return self._min_success_ratio + + @min_successes.setter + def min_successes(self, value): + self._min_successes = value + + def to_dict(self): + """ + :rtype: dict[T, Text] + """ + array_job = None + if self.min_successes is not None: + array_job = _array_job.ArrayJob( + parallelism=self.parallelism, + size=self.size, + min_successes=self.min_successes, + ) + elif self.min_success_ratio is not None: + array_job = _array_job.ArrayJob( + parallelism=self.parallelism, + size=self.size, + min_success_ratio=self.min_success_ratio, + ) + + return _json_format.MessageToDict(array_job) + + @classmethod + def from_dict(cls, idl_dict): + """ + :param dict[T, Text] idl_dict: + :rtype: ArrayJob + """ + pb2_object = _json_format.Parse(_json.dumps(idl_dict), _array_job.ArrayJob()) + + if pb2_object.HasField("min_successes"): + return cls( + parallelism=pb2_object.parallelism, + size=pb2_object.size, + min_successes=pb2_object.min_successes, + ) + else: + return cls( + parallelism=pb2_object.parallelism, + size=pb2_object.size, + min_success_ratio=pb2_object.min_success_ratio, + ) diff --git a/flytekit/flytekit/models/common.py b/flytekit/flytekit/models/common.py new file mode 100644 index 0000000000..79392700e2 --- /dev/null +++ b/flytekit/flytekit/models/common.py @@ -0,0 +1,505 @@ +import abc as _abc +import json as _json +import re +from typing import Dict + +from flyteidl.admin import common_pb2 as _common_pb2 +from flyteidl.core import literals_pb2 as _literals_pb2 +from google.protobuf import json_format as _json_format +from google.protobuf import struct_pb2 as _struct + + +class FlyteABCMeta(_abc.ABCMeta): + def __instancecheck__(cls, instance): + if cls in type(instance).__mro__: + return True + return super(FlyteABCMeta, cls).__instancecheck__(instance) + + +class FlyteType(FlyteABCMeta): + def __repr__(cls): + return cls.short_class_string() + + def __str__(cls): + return cls.verbose_class_string() + + def short_class_string(cls): + """ + :rtype: Text + """ + return super(FlyteType, cls).__repr__() + + def verbose_class_string(cls): + """ + :rtype: Text + """ + return cls.short_class_string() + + @_abc.abstractmethod + def from_flyte_idl(cls, idl_object): + pass + + +class FlyteIdlEntity(object, metaclass=FlyteType): + def __eq__(self, other): + return isinstance(other, FlyteIdlEntity) and other.to_flyte_idl() == self.to_flyte_idl() + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return self.short_string() + + def __str__(self): + return self.verbose_string() + + def __hash__(self): + return hash(self.to_flyte_idl().SerializeToString(deterministic=True)) + + def short_string(self): + """ + :rtype: Text + """ + literal_str = re.sub(r"\s+", " ", str(self.to_flyte_idl())).strip() + return f"" + + def verbose_string(self): + """ + :rtype: Text + """ + return self.short_string() + + def serialize_to_string(self) -> str: + return self.to_flyte_idl().SerializeToString() + + @property + def is_empty(self): + return len(self.to_flyte_idl().SerializeToString()) == 0 + + @_abc.abstractmethod + def to_flyte_idl(self): + pass + + +class FlyteCustomIdlEntity(FlyteIdlEntity): + @classmethod + def from_flyte_idl(cls, idl_object): + """ + + :param _struct.Struct idl_object: + :return: FlyteCustomIdlEntity + """ + return cls.from_dict(idl_dict=_json_format.MessageToDict(idl_object)) + + def to_flyte_idl(self): + return _json_format.Parse(_json.dumps(self.to_dict()), _struct.Struct()) + + @_abc.abstractmethod + def from_dict(self, idl_dict): + pass + + @_abc.abstractmethod + def to_dict(self): + """ + Converts self to a dictionary. + :rtype: dict[Text, T] + """ + pass + + +class NamedEntityIdentifier(FlyteIdlEntity): + def __init__(self, project, domain, name=None): + """ + :param Text project: The name of the project in which this entity lives. + :param Text domain: The name of the domain within the project. + :param Text name: [Optional] The name of the entity within the namespace of the project and domain. + """ + self._project = project + self._domain = domain + self._name = name + + @property + def project(self): + """ + The name of the project in which this entity lives. + :rtype: Text + """ + return self._project + + @property + def domain(self): + """ + The name of the domain within the project. + :rtype: Text + """ + return self._domain + + @property + def name(self): + """ + The name of the entity within the namespace of the project and domain. + :rtype: Text + """ + return self._name + + def to_flyte_idl(self): + """ + Stores object to a Flyte-IDL defined protobuf. + :rtype: flyteidl.admin.common_pb2.NamedEntityIdentifier + """ + + # We use the kwarg constructor of the protobuf and setting name=None is equivalent to not setting it at all + return _common_pb2.NamedEntityIdentifier(project=self.project, domain=self.domain, name=self.name) + + @classmethod + def from_flyte_idl(cls, idl_object): + """ + :param flyteidl.admin.common_pb2.NamedEntityIdentifier idl_object: + :rtype: NamedEntityIdentifier + """ + return cls(idl_object.project, idl_object.domain, idl_object.name) + + +class EmailNotification(FlyteIdlEntity): + def __init__(self, recipients_email): + """ + :param list[Text] recipients_email: + """ + self._recipients_email = recipients_email + + @property + def recipients_email(self): + """ + :rtype: list[Text] + """ + return self._recipients_email + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.EmailNotification + """ + return _common_pb2.EmailNotification(recipients_email=self.recipients_email) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.common_pb2.EmailNotification pb2_object: + :rtype: EmailNotification + """ + return cls(pb2_object.recipients_email) + + +class SlackNotification(FlyteIdlEntity): + def __init__(self, recipients_email): + """ + :param list[Text] recipients_email: + """ + self._recipients_email = recipients_email + + @property + def recipients_email(self): + """ + :rtype: list[Text] + """ + return self._recipients_email + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.SlackNotification + """ + return _common_pb2.SlackNotification(recipients_email=self.recipients_email) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.common_pb2.SlackNotification pb2_object: + :rtype: EmailNotification + """ + return cls(pb2_object.recipients_email) + + +class PagerDutyNotification(FlyteIdlEntity): + def __init__(self, recipients_email): + """ + :param list[Text] recipients_email: + """ + self._recipients_email = recipients_email + + @property + def recipients_email(self): + """ + :rtype: list[Text] + """ + return self._recipients_email + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.PagerDutyNotification + """ + return _common_pb2.PagerDutyNotification(recipients_email=self.recipients_email) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.common_pb2.PagerDutyNotification pb2_object: + :rtype: EmailNotification + """ + return cls(pb2_object.recipients_email) + + +class Notification(FlyteIdlEntity): + def __init__( + self, + phases, + email: EmailNotification = None, + pager_duty: PagerDutyNotification = None, + slack: SlackNotification = None, + ): + """ + Represents a structure for notifications based on execution status. + + :param list[int] phases: A list of phases to which users can associate the notifications. + :param EmailNotification email: [Optional] Specify this for an email notification. + :param PagerDutyNotification email: [Optional] Specify this for a PagerDuty notification. + :param SlackNotification email: [Optional] Specify this for a Slack notification. + """ + self._phases = phases + self._email = email + self._pager_duty = pager_duty + self._slack = slack + + @property + def phases(self): + """ + A list of phases to which users can associate the notifications. + :rtype: list[int] + """ + return self._phases + + @property + def email(self): + """ + :rtype: EmailNotification + """ + return self._email + + @property + def pager_duty(self): + """ + :rtype: PagerDutyNotification + """ + return self._pager_duty + + @property + def slack(self): + """ + :rtype: SlackNotification + """ + return self._slack + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.Notification + """ + return _common_pb2.Notification( + phases=self.phases, + email=self.email.to_flyte_idl() if self.email else None, + pager_duty=self.pager_duty.to_flyte_idl() if self.pager_duty else None, + slack=self.slack.to_flyte_idl() if self.slack else None, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.admin.common_pb2.Notification p: + :rtype: Notification + """ + return cls( + p.phases, + email=EmailNotification.from_flyte_idl(p.email) if p.HasField("email") else None, + pager_duty=PagerDutyNotification.from_flyte_idl(p.pager_duty) if p.HasField("pager_duty") else None, + slack=SlackNotification.from_flyte_idl(p.slack) if p.HasField("slack") else None, + ) + + +class Labels(FlyteIdlEntity): + def __init__(self, values): + """ + Label values to be applied to a workflow execution resource. + + :param dict[Text, Text] values: + """ + self._values = values + + @property + def values(self): + return self._values + + def to_flyte_idl(self): + """ + :rtype: dict[Text, Text] + """ + return _common_pb2.Labels(values={k: v for k, v in self.values.items()}) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.common_pb2.Labels pb2_object: + :rtype: Labels + """ + return cls({k: v for k, v in pb2_object.values.items()}) + + +class Annotations(FlyteIdlEntity): + def __init__(self, values): + """ + Annotation values to be applied to a workflow execution resource. + + :param dict[Text, Text] values: + """ + self._values = values + + @property + def values(self): + return self._values + + def to_flyte_idl(self): + """ + :rtype: _common_pb2.Annotations + """ + return _common_pb2.Annotations(values={k: v for k, v in self.values.items()}) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.common_pb2.Annotations pb2_object: + :rtype: Annotations + """ + return cls({k: v for k, v in pb2_object.values.items()}) + + +class UrlBlob(FlyteIdlEntity): + def __init__(self, url, bytes): + """ + :param Text url: + :param int bytes: + """ + self._url = url + self._bytes = bytes + + @property + def url(self): + """ + :rtype: Text + """ + return self._url + + @property + def bytes(self): + """ + :rtype: int + """ + return self._bytes + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.UrlBlob + """ + return _common_pb2.UrlBlob(url=self.url, bytes=self.bytes) + + @classmethod + def from_flyte_idl(cls, pb): + """ + :param flyteidl.admin.common_pb2.UrlBlob pb: + :rtype: UrlBlob + """ + return cls(url=pb.url, bytes=pb.bytes) + + +class AuthRole(FlyteIdlEntity): + def __init__(self, assumable_iam_role=None, kubernetes_service_account=None): + """Auth configuration for IAM or K8s service account. + + Either one or both of the assumable IAM role and/or the K8s service account can be set. + + :param Text assumable_iam_role: IAM identity with set permissions policies. + :param Text kubernetes_service_account: Provides an identity for workflow execution resources. + Flyte deployment administrators are responsible for handling permissions as they + relate to the service account. + """ + self._assumable_iam_role = assumable_iam_role + self._kubernetes_service_account = kubernetes_service_account + + @property + def assumable_iam_role(self): + """ + The IAM role to execute the workflow with + :rtype: Text + """ + return self._assumable_iam_role + + @property + def kubernetes_service_account(self): + """ + The kubernetes service account to execute the workflow with + :rtype: Text + """ + return self._kubernetes_service_account + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.launch_plan_pb2.Auth + """ + return _common_pb2.AuthRole( + assumable_iam_role=self.assumable_iam_role if self.assumable_iam_role else None, + kubernetes_service_account=self.kubernetes_service_account if self.kubernetes_service_account else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.launch_plan_pb2.Auth pb2_object: + :rtype: Auth + """ + return cls( + assumable_iam_role=pb2_object.assumable_iam_role, + kubernetes_service_account=pb2_object.kubernetes_service_account, + ) + + +class RawOutputDataConfig(FlyteIdlEntity): + def __init__(self, output_location_prefix): + """ + :param Text output_location_prefix: Location of offloaded data for things like S3, etc. + """ + self._output_location_prefix = output_location_prefix + + @property + def output_location_prefix(self): + return self._output_location_prefix + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.Auth + """ + return _common_pb2.RawOutputDataConfig(output_location_prefix=self.output_location_prefix) + + @classmethod + def from_flyte_idl(cls, pb2): + return cls(output_location_prefix=pb2.output_location_prefix) + + +class Envs(FlyteIdlEntity): + def __init__(self, envs: Dict[str, str]): + self._envs = envs + + @property + def envs(self) -> Dict[str, str]: + return self._envs + + def to_flyte_idl(self) -> _common_pb2.Envs: + return _common_pb2.Envs(values=[_literals_pb2.KeyValuePair(key=k, value=v) for k, v in self.envs.items()]) + + @classmethod + def from_flyte_idl(cls, pb2: _common_pb2.Envs) -> _common_pb2.Envs: + return cls(envs={kv.key: kv.value for kv in pb2.values}) diff --git a/flytekit/flytekit/models/core/__init__.py b/flytekit/flytekit/models/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/models/core/catalog.py b/flytekit/flytekit/models/core/catalog.py new file mode 100644 index 0000000000..97cc2c34ff --- /dev/null +++ b/flytekit/flytekit/models/core/catalog.py @@ -0,0 +1,75 @@ +from flyteidl.core import catalog_pb2 + +from flytekit.models import common as _common_models +from flytekit.models.core import identifier as _identifier + + +class CatalogArtifactTag(_common_models.FlyteIdlEntity): + def __init__(self, artifact_id: str, name: str): + self._artifact_id = artifact_id + self._name = name + + @property + def artifact_id(self) -> str: + return self._artifact_id + + @property + def name(self) -> str: + return self._name + + def to_flyte_idl(self) -> catalog_pb2.CatalogArtifactTag: + return catalog_pb2.CatalogArtifactTag(artifact_id=self.artifact_id, name=self.name) + + @classmethod + def from_flyte_idl(cls, p: catalog_pb2.CatalogArtifactTag) -> "CatalogArtifactTag": + return cls( + artifact_id=p.artifact_id, + name=p.name, + ) + + +class CatalogMetadata(_common_models.FlyteIdlEntity): + def __init__( + self, + dataset_id: _identifier.Identifier, + artifact_tag: CatalogArtifactTag, + source_task_execution: _identifier.TaskExecutionIdentifier, + ): + self._dataset_id = dataset_id + self._artifact_tag = artifact_tag + self._source_task_execution = source_task_execution + + @property + def dataset_id(self) -> _identifier.Identifier: + return self._dataset_id + + @property + def artifact_tag(self) -> CatalogArtifactTag: + return self._artifact_tag + + @property + def source_task_execution(self) -> _identifier.TaskExecutionIdentifier: + return self._source_task_execution + + @property + def source_execution(self) -> _identifier.TaskExecutionIdentifier: + """ + This is a one of but for now there's only one thing in the one of + """ + return self._source_task_execution + + def to_flyte_idl(self) -> catalog_pb2.CatalogMetadata: + return catalog_pb2.CatalogMetadata( + dataset_id=self.dataset_id.to_flyte_idl(), + artifact_tag=self.artifact_tag.to_flyte_idl(), + source_task_execution=self.source_task_execution.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb: catalog_pb2.CatalogMetadata) -> "CatalogMetadata": + return cls( + dataset_id=_identifier.Identifier.from_flyte_idl(pb.dataset_id), + artifact_tag=CatalogArtifactTag.from_flyte_idl(pb.artifact_tag), + # Add HasField check if more things are ever added to the one of + source_task_execution=_identifier.TaskExecutionIdentifier.from_flyte_idl(pb.source_task_execution), + ) diff --git a/flytekit/flytekit/models/core/compiler.py b/flytekit/flytekit/models/core/compiler.py new file mode 100644 index 0000000000..929f816227 --- /dev/null +++ b/flytekit/flytekit/models/core/compiler.py @@ -0,0 +1,210 @@ +from flyteidl.core import compiler_pb2 as _compiler_pb2 + +from flytekit.models import common as _common +from flytekit.models.core import workflow as _core_workflow_models + + +class ConnectionSet(_common.FlyteIdlEntity): + class IdList(_common.FlyteIdlEntity): + def __init__(self, ids): + """ + :param list[Text] ids: + """ + self._ids = ids + + @property + def ids(self): + """ + :rtype: list[Text] + """ + return self._ids + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.compiler_pb2.ConnectionSet.IdList + """ + return _compiler_pb2.ConnectionSet.IdList(ids=self.ids) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.compiler_pb2.ConnectionSet.IdList p: + :rtype: ConnectionSet.IdList + """ + return cls(p.ids) + + def __init__(self, upstream, downstream): + """ + :param dict[Text, ConnectionSet.IdList] upstream: + :param dict[Text, ConnectionSet.IdList] downstream: + """ + self._upstream = upstream + self._downstream = downstream + + @property + def upstream(self): + """ + :rtype: dict[Text, ConnectionSet.IdList] + """ + return self._upstream + + @property + def downstream(self): + """ + :rtype: dict[Text, ConnectionSet.IdList] + """ + return self._downstream + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.compiler_pb2.ConnectionSet + """ + return _compiler_pb2.ConnectionSet( + upstream={k: v.to_flyte_idl() for k, v in self.upstream.items()}, + downstream={k: v.to_flyte_idl() for k, v in self.upstream.items()}, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.compiler_pb2.ConnectionSet p: + :rtype: ConnectionSet + """ + return cls( + upstream={k: ConnectionSet.IdList.from_flyte_idl(v) for k, v in p.upstream.items()}, + downstream={k: ConnectionSet.IdList.from_flyte_idl(v) for k, v in p.downstream.items()}, + ) + + +class CompiledWorkflow(_common.FlyteIdlEntity): + def __init__(self, template, connections): + """ + :param flytekit.models.core.workflow.WorkflowTemplate template: + :param ConnectionSet connections: + """ + self._template = template + self._connections = connections + + @property + def template(self): + """ + :rtype: flytekit.models.core.workflow.WorkflowTemplate + """ + return self._template + + @property + def connections(self): + """ + :rtype: ConnectionSet + """ + return self._connections + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.compiler_pb2.CompiledWorkflow + """ + return _compiler_pb2.CompiledWorkflow( + template=self.template.to_flyte_idl(), + connections=self.connections.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.compiler_pb2.CompiledWorkflow p: + :rtype: CompiledWorkflow + """ + return cls( + template=_core_workflow_models.WorkflowTemplate.from_flyte_idl(p.template), + connections=ConnectionSet.from_flyte_idl(p.connections), + ) + + +# TODO: properly sort out the model code and remove one of these duplicate CompiledTasks +class CompiledTask(_common.FlyteIdlEntity): + def __init__(self, template): + """ + :param TODO template: + """ + self._template = template + + @property + def template(self): + """ + :rtype: TODO + """ + return self._template + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.compiler_pb2.CompiledTask + """ + return _compiler_pb2.CompiledTask(template=self.template) # TODO: .to_flyte_idl() + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.compiler_pb2.CompiledTask p: + :rtype: CompiledTask + """ + # TODO: Refactor task so we don't have cyclical import + return cls(None) + + +class CompiledWorkflowClosure(_common.FlyteIdlEntity): + def __init__(self, primary, sub_workflows, tasks): + """ + :param CompiledWorkflow primary: + :param list[CompiledWorkflow] sub_workflows: + :param list[CompiledTask] tasks: + """ + self._primary = primary + self._sub_workflows = sub_workflows + self._tasks = tasks + + @property + def primary(self): + """ + :rtype: CompiledWorkflow + """ + return self._primary + + @property + def sub_workflows(self): + """ + :rtype: list[CompiledWorkflow] + """ + return self._sub_workflows + + @property + def tasks(self): + """ + :rtype: list[CompiledTask] + """ + return self._tasks + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.compiler_pb2.CompiledWorkflowClosure + """ + return _compiler_pb2.CompiledWorkflowClosure( + primary=self.primary.to_flyte_idl(), + sub_workflows=[s.to_flyte_idl() for s in self.sub_workflows], + tasks=[t.to_flyte_idl() for t in self.tasks], + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.compiler_pb2.CompiledWorkflowClosure p: + :rtype: CompiledWorkflowClosure + """ + # This import is here to prevent a circular dependency issue. + # TODO: properly sort out the model code and remove the duplicate CompiledTask + from flytekit.models.task import CompiledTask as _CompiledTask + + return cls( + primary=CompiledWorkflow.from_flyte_idl(p.primary), + sub_workflows=[CompiledWorkflow.from_flyte_idl(s) for s in p.sub_workflows], + tasks=[_CompiledTask.from_flyte_idl(t) for t in p.tasks], + ) diff --git a/flytekit/flytekit/models/core/condition.py b/flytekit/flytekit/models/core/condition.py new file mode 100644 index 0000000000..27e0bc505b --- /dev/null +++ b/flytekit/flytekit/models/core/condition.py @@ -0,0 +1,241 @@ +from flyteidl.core import condition_pb2 as _condition + +from flytekit.models import common as _common +from flytekit.models import literals as _literals + + +class ComparisonExpression(_common.FlyteIdlEntity): + class Operator(object): + """ + Binary Operator for each expression + """ + + EQ = _condition.ComparisonExpression.EQ + NEQ = _condition.ComparisonExpression.NEQ + GT = _condition.ComparisonExpression.GT + GTE = _condition.ComparisonExpression.GTE + LT = _condition.ComparisonExpression.LT + LTE = _condition.ComparisonExpression.LTE + + def __init__(self, operator, left_value, right_value): + """ + Defines a 2-level tree where the root is a comparison operator and Operands are primitives or known variables. + Each expression results in a boolean result. + + :param Operator operator: + :param Operand left_value: + :param Operand right_value: + """ + self._operator = operator + self._left_value = left_value + self._right_value = right_value + + @property + def operator(self): + """ + Gets the operator representing this comparison expression. + :rtype: ComparisonExpression.Operator + """ + return self._operator + + @property + def left_value(self): + """ + Gets the left value for the comparison expression. + :rtype: Operand + """ + return self._left_value + + @property + def right_value(self): + """ + Gets the right value for the comparison expression. + :rtype: Operand + """ + return self._right_value + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.condition_pb2.ComparisonExpression + """ + return _condition.ComparisonExpression( + operator=self.operator, + left_value=self.left_value.to_flyte_idl(), + right_value=self.right_value.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + operator=pb2_object.operator, + left_value=Operand.from_flyte_idl(pb2_object.left_value), + right_value=Operand.from_flyte_idl(pb2_object.right_value), + ) + + +class ConjunctionExpression(_common.FlyteIdlEntity): + class LogicalOperator(object): + AND = _condition.ConjunctionExpression.AND + OR = _condition.ConjunctionExpression.OR + + def __init__(self, operator, left_expression, right_expression): + """ + Defines a conjunction expression of two boolean expressions. + :param LogicalOperator operator: + :param BooleanExpression left_expression: + :param BooleanExpression right_expression: + """ + + self._operator = operator + self._left_expression = left_expression + self._right_expression = right_expression + + @property + def operator(self): + """ + Gets the operator representing this conjunction expression. + :rtype: ConjunctionExpression.LogicalOperator + """ + return self._operator + + @property + def left_expression(self): + """ + Gets the left value for the conjunction expression. + :rtype: Operand + """ + return self._left_expression + + @property + def right_expression(self): + """ + Gets the right value for the conjunction expression. + :rtype: Operand + """ + return self._right_expression + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.condition_pb2.ConjunctionExpression + """ + return _condition.ConjunctionExpression( + operator=self.operator, + left_expression=self.left_expression.to_flyte_idl(), + right_expression=self.right_expression.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + operator=pb2_object.operator, + left_expression=BooleanExpression.from_flyte_idl(pb2_object.left_expression), + right_expression=BooleanExpression.from_flyte_idl(pb2_object.right_expression), + ) + + +class Operand(_common.FlyteIdlEntity): + def __init__(self, primitive=None, var=None, scalar=None): + """ + Defines an operand to a comparison expression. + :param flytekit.models.literals.Primitive primitive: A primitive value + :param Text var: A variable name + :param flytekit.models.literals.Scalar scalar: A scalar value + """ + + self._primitive = primitive + self._var = var + self._scalar = scalar + + @property + def primitive(self): + """ + :rtype: flytekit.models.literals.Primitive + """ + + return self._primitive + + @property + def var(self): + """ + :rtype: Text + """ + + return self._var + + @property + def scalar(self): + """ + :rtype: flytekit.models.literals.Scalar + """ + + return self._scalar + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.condition_pb2.Operand + """ + return _condition.Operand( + primitive=self.primitive.to_flyte_idl() if self.primitive else None, + var=self.var if self.var else None, + scalar=self.scalar.to_flyte_idl() if self.scalar else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + primitive=_literals.Primitive.from_flyte_idl(pb2_object.primitive) + if pb2_object.HasField("primitive") + else None, + var=pb2_object.var if pb2_object.HasField("var") else None, + scalar=_literals.Scalar.from_flyte_idl(pb2_object.scalar) if pb2_object.HasField("scalar") else None, + ) + + +class BooleanExpression(_common.FlyteIdlEntity): + def __init__(self, conjunction=None, comparison=None): + """ + Defines a boolean expression tree. It can be a simple or a conjunction expression. + Multiple expressions can be combined using a conjunction or a disjunction to result in a final boolean result. + + :param ConjunctionExpression conjunction: + :param ComparisonExpression comparison: + """ + + self._conjunction = conjunction + self._comparison = comparison + + @property + def conjunction(self): + """ + Conjunction expression or None if not set. + :rtype: ConjunctionExpression + """ + return self._conjunction + + @property + def comparison(self): + """ + Comparison expression or None if not set. + :rtype: ComparisonExpression + """ + return self._comparison + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.condition_pb2.BooleanExpression + """ + return _condition.BooleanExpression( + conjunction=self.conjunction.to_flyte_idl() if self.conjunction else None, + comparison=self.comparison.to_flyte_idl() if self.comparison else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + conjunction=ConjunctionExpression.from_flyte_idl(pb2_object.conjunction) + if pb2_object.HasField("conjunction") + else None, + comparison=ComparisonExpression.from_flyte_idl(pb2_object.comparison) + if pb2_object.HasField("comparison") + else None, + ) diff --git a/flytekit/flytekit/models/core/errors.py b/flytekit/flytekit/models/core/errors.py new file mode 100644 index 0000000000..28bbdfedd3 --- /dev/null +++ b/flytekit/flytekit/models/core/errors.py @@ -0,0 +1,93 @@ +from flyteidl.core import errors_pb2 as _errors_pb2 + +from flytekit.models import common as _common + + +class ContainerError(_common.FlyteIdlEntity): + class Kind(object): + NON_RECOVERABLE = _errors_pb2.ContainerError.NON_RECOVERABLE + RECOVERABLE = _errors_pb2.ContainerError.RECOVERABLE + + def __init__(self, code: str, message: str, kind: int, origin: int): + """ + :param code: A succinct code about the error + :param message: Whatever message you want to surface about the error + :param kind: A value from the ContainerError.Kind enum. + :param origin: A value from ExecutionError.ErrorKind. Don't confuse this with error kind, even though + both are called kind. + """ + self._code = code + self._message = message + self._kind = kind + self._origin = origin + + @property + def code(self): + """ + :rtype: Text + """ + return self._code + + @property + def message(self): + """ + :rtype: Text + """ + return self._message + + @property + def kind(self): + """ + :rtype: int + """ + return self._kind + + @property + def origin(self) -> int: + """ + The origin of the error, an enum value from ExecutionError.ErrorKind + """ + return self._origin + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.errors_pb2.ContainerError + """ + return _errors_pb2.ContainerError(code=self.code, message=self.message, kind=self.kind, origin=self.origin) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.errors_pb2.ContainerError proto: + :rtype: ContainerError + """ + return cls(proto.code, proto.message, proto.kind, proto.origin) + + +class ErrorDocument(_common.FlyteIdlEntity): + def __init__(self, error): + """ + :param ContainerError error: + """ + self._error = error + + @property + def error(self): + """ + :rtype: ContainerError + """ + return self._error + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.errors_pb2.ErrorDocument + """ + return _errors_pb2.ErrorDocument(error=self.error.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.errors_pb2.ErrorDocument proto: + :rtype: ErrorDocument + """ + return cls(ContainerError.from_flyte_idl(proto.error)) diff --git a/flytekit/flytekit/models/core/execution.py b/flytekit/flytekit/models/core/execution.py new file mode 100644 index 0000000000..35411e1a2a --- /dev/null +++ b/flytekit/flytekit/models/core/execution.py @@ -0,0 +1,224 @@ +import datetime +import typing + +from flyteidl.core import execution_pb2 as _execution_pb2 + +from flytekit.models import common as _common + + +class WorkflowExecutionPhase(object): + """ + This class holds enum values used for setting notifications. See :py:class:`flytekit.Email` + for sample usage. + """ + + UNDEFINED = _execution_pb2.WorkflowExecution.UNDEFINED + QUEUED = _execution_pb2.WorkflowExecution.QUEUED + RUNNING = _execution_pb2.WorkflowExecution.RUNNING + SUCCEEDING = _execution_pb2.WorkflowExecution.SUCCEEDING + SUCCEEDED = _execution_pb2.WorkflowExecution.SUCCEEDED + FAILING = _execution_pb2.WorkflowExecution.FAILING + FAILED = _execution_pb2.WorkflowExecution.FAILED + ABORTED = _execution_pb2.WorkflowExecution.ABORTED + TIMED_OUT = _execution_pb2.WorkflowExecution.TIMED_OUT + ABORTING = _execution_pb2.WorkflowExecution.ABORTING + + @classmethod + def enum_to_string(cls, int_value): + """ + :param int_value: + :rtype: Text + """ + for name, value in cls.__dict__.items(): + if value == int_value: + return name + return str(int_value) + + +class NodeExecutionPhase(object): + UNDEFINED = _execution_pb2.NodeExecution.UNDEFINED + QUEUED = _execution_pb2.NodeExecution.QUEUED + RUNNING = _execution_pb2.NodeExecution.RUNNING + SUCCEEDED = _execution_pb2.NodeExecution.SUCCEEDED + FAILING = _execution_pb2.NodeExecution.FAILING + FAILED = _execution_pb2.NodeExecution.FAILED + ABORTED = _execution_pb2.NodeExecution.ABORTED + SKIPPED = _execution_pb2.NodeExecution.SKIPPED + TIMED_OUT = _execution_pb2.NodeExecution.TIMED_OUT + DYNAMIC_RUNNING = _execution_pb2.NodeExecution.DYNAMIC_RUNNING + RECOVERED = _execution_pb2.NodeExecution.RECOVERED + + @classmethod + def enum_to_string(cls, int_value): + """ + :param int_value: + :rtype: Text + """ + for name, value in cls.__dict__.items(): + if value == int_value: + return name + return str(int_value) + + +class TaskExecutionPhase(object): + UNDEFINED = _execution_pb2.TaskExecution.UNDEFINED + RUNNING = _execution_pb2.TaskExecution.RUNNING + SUCCEEDED = _execution_pb2.TaskExecution.SUCCEEDED + FAILED = _execution_pb2.TaskExecution.FAILED + ABORTED = _execution_pb2.TaskExecution.ABORTED + QUEUED = _execution_pb2.TaskExecution.QUEUED + INITIALIZING = _execution_pb2.TaskExecution.INITIALIZING + WAITING_FOR_RESOURCES = _execution_pb2.TaskExecution.WAITING_FOR_RESOURCES + + @classmethod + def enum_to_string(cls, int_value): + """ + :param int_value: + :rtype: Text + """ + for name, value in cls.__dict__.items(): + if value == int_value: + return name + return str(int_value) + + +class ExecutionError(_common.FlyteIdlEntity): + class ErrorKind(object): + UNKNOWN = _execution_pb2.ExecutionError.ErrorKind.UNKNOWN + USER = _execution_pb2.ExecutionError.ErrorKind.USER + SYSTEM = _execution_pb2.ExecutionError.ErrorKind.SYSTEM + + def __init__(self, code: str, message: str, error_uri: str, kind: int): + """ + :param code: + :param message: + :param uri: + :param kind: + """ + self._code = code + self._message = message + self._error_uri = error_uri + self._kind = kind + + @property + def code(self): + """ + :rtype: Text + """ + return self._code + + @property + def message(self): + """ + :rtype: Text + """ + return self._message + + @property + def error_uri(self): + """ + :rtype: Text + """ + return self._error_uri + + @property + def kind(self) -> int: + """ + Enum value from ErrorKind + """ + return self._kind + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.execution_pb2.ExecutionError + """ + return _execution_pb2.ExecutionError( + code=self.code, + message=self.message, + error_uri=self.error_uri, + kind=self.kind, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.execution_pb2.ExecutionError p: + :rtype: ExecutionError + """ + return cls(code=p.code, message=p.message, error_uri=p.error_uri, kind=p.kind) + + +class TaskLog(_common.FlyteIdlEntity): + class MessageFormat(object): + UNKNOWN = _execution_pb2.TaskLog.UNKNOWN + CSV = _execution_pb2.TaskLog.CSV + JSON = _execution_pb2.TaskLog.JSON + + def __init__( + self, + uri: str, + name: str, + message_format: typing.Optional[MessageFormat] = None, + ttl: typing.Optional[datetime.timedelta] = None, + ): + """ + :param Text uri: + :param Text name: + :param MessageFormat message_format: Enum value from TaskLog.MessageFormat + :param datetime.timedelta ttl: The time the log will persist for. 0 represents unknown or ephemeral in nature. + """ + self._uri = uri + self._name = name + self._message_format = message_format + self._ttl = ttl + + @property + def uri(self): + """ + :rtype: Text + """ + return self._uri + + @property + def name(self): + """ + :rtype: Text + """ + return self._name + + @property + def message_format(self): + """ + Enum value from TaskLog.MessageFormat + :rtype: MessageFormat + """ + return self._message_format + + @property + def ttl(self): + """ + :rtype: datetime.timedelta + """ + return self._ttl + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.execution_pb2.TaskLog + """ + p = _execution_pb2.TaskLog(uri=self.uri, name=self.name, message_format=self.message_format) + if self.ttl is not None: + p.ttl.FromTimedelta(self.ttl) + return p + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.execution_pb2.TaskLog p: + :rtype: TaskLog + """ + return cls( + uri=p.uri, + name=p.name, + message_format=p.message_format, + ttl=p.ttl.ToTimedelta() if p.ttl else None, + ) diff --git a/flytekit/flytekit/models/core/identifier.py b/flytekit/flytekit/models/core/identifier.py new file mode 100644 index 0000000000..8a45232e38 --- /dev/null +++ b/flytekit/flytekit/models/core/identifier.py @@ -0,0 +1,282 @@ +from flyteidl.core import identifier_pb2 as identifier_pb2 + +from flytekit.models import common as _common_models + + +class ResourceType(object): + UNSPECIFIED = identifier_pb2.UNSPECIFIED + TASK = identifier_pb2.TASK + WORKFLOW = identifier_pb2.WORKFLOW + LAUNCH_PLAN = identifier_pb2.LAUNCH_PLAN + + +class Identifier(_common_models.FlyteIdlEntity): + def __init__(self, resource_type, project, domain, name, version): + """ + :param int resource_type: enum value from ResourceType + :param Text project: + :param Text domain: + :param Text name: + :param Text version: + """ + self._resource_type = resource_type + self._project = project + self._domain = domain + self._name = name + self._version = version + + @property + def resource_type(self): + """ + enum value from ResourceType + :rtype: int + """ + return self._resource_type + + def resource_type_name(self) -> str: + return identifier_pb2.ResourceType.Name(self.resource_type) + + @property + def project(self): + """ + :rtype: Text + """ + return self._project + + @property + def domain(self): + """ + :rtype: Text + """ + return self._domain + + @property + def name(self): + """ + :rtype: Text + """ + return self._name + + @property + def version(self): + """ + :rtype: Text + """ + return self._version + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.identifier_pb2.Identifier + """ + return identifier_pb2.Identifier( + resource_type=self.resource_type, + project=self.project, + domain=self.domain, + name=self.name, + version=self.version, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.identifier_pb2.Identifier p: + :rtype: Identifier + """ + return cls( + resource_type=p.resource_type, + project=p.project, + domain=p.domain, + name=p.name, + version=p.version, + ) + + def __repr__(self): + return self.__str__() + + def __str__(self): + return f"{self.resource_type_name()}:{self.project}:{self.domain}:{self.name}:{self.version}" + + +class WorkflowExecutionIdentifier(_common_models.FlyteIdlEntity): + def __init__(self, project, domain, name): + """ + :param Text project: + :param Text domain: + :param Text name: + """ + self._project = project + self._domain = domain + self._name = name + + @property + def project(self): + """ + :rtype: Text + """ + return self._project + + @property + def domain(self): + """ + :rtype: Text + """ + return self._domain + + @property + def name(self): + """ + :rtype: Text + """ + return self._name + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.identifier_pb2.WorkflowExecutionIdentifier + """ + return identifier_pb2.WorkflowExecutionIdentifier( + project=self.project, + domain=self.domain, + name=self.name, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.identifier_pb2.WorkflowExecutionIdentifier p: + :rtype: WorkflowExecutionIdentifier + """ + return cls( + project=p.project, + domain=p.domain, + name=p.name, + ) + + +class NodeExecutionIdentifier(_common_models.FlyteIdlEntity): + def __init__(self, node_id, execution_id): + """ + :param Text node_id: + :param WorkflowExecutionIdentifier execution_id: + """ + self._node_id = node_id + self._execution_id = execution_id + + @property + def node_id(self): + """ + :rtype: Text + """ + return self._node_id + + @property + def execution_id(self): + """ + :rtype: WorkflowExecutionIdentifier + """ + return self._execution_id + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.identifier_pb2.NodeExecutionIdentifier + """ + return identifier_pb2.NodeExecutionIdentifier( + node_id=self.node_id, + execution_id=self.execution_id.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.identifier_pb2.NodeExecutionIdentifier p: + :rtype: NodeExecutionIdentifier + """ + return cls( + node_id=p.node_id, + execution_id=WorkflowExecutionIdentifier.from_flyte_idl(p.execution_id), + ) + + +class TaskExecutionIdentifier(_common_models.FlyteIdlEntity): + def __init__(self, task_id, node_execution_id, retry_attempt): + """ + :param Identifier task_id: The identifier for the task that is executing + :param NodeExecutionIdentifier node_execution_id: The identifier for the node that owns this execution. + :param int retry_attempt: The attempt for executing this task by the owning node. + """ + self._task_id = task_id + self._node_execution_id = node_execution_id + self._retry_attempt = retry_attempt + + @property + def task_id(self): + """ + :rtype: Identifier + """ + return self._task_id + + @property + def node_execution_id(self): + """ + :rtype: NodeExecutionIdentifier + """ + return self._node_execution_id + + @property + def retry_attempt(self): + """ + :rtype: int + """ + return self._retry_attempt + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.identifier_pb2.TaskExecutionIdentifier + """ + return identifier_pb2.TaskExecutionIdentifier( + task_id=self.task_id.to_flyte_idl(), + node_execution_id=self.node_execution_id.to_flyte_idl(), + retry_attempt=self.retry_attempt, + ) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.identifier_pb2.TaskExecutionIdentifier proto: + :rtype: TaskExecutionIdentifier + """ + return cls( + task_id=Identifier.from_flyte_idl(proto.task_id), + node_execution_id=NodeExecutionIdentifier.from_flyte_idl(proto.node_execution_id), + retry_attempt=proto.retry_attempt, + ) + + +class SignalIdentifier(_common_models.FlyteIdlEntity): + def __init__(self, signal_id: str, execution_id: WorkflowExecutionIdentifier): + """ + :param signal_id: User provided name for the gate node. + :param execution_id: The workflow execution id this signal is for. + """ + self._signal_id = signal_id + self._execution_id = execution_id + + @property + def signal_id(self) -> str: + return self._signal_id + + @property + def execution_id(self) -> WorkflowExecutionIdentifier: + return self._execution_id + + def to_flyte_idl(self) -> identifier_pb2.SignalIdentifier: + return identifier_pb2.SignalIdentifier( + signal_id=self.signal_id, + execution_id=self.execution_id.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, proto: identifier_pb2.SignalIdentifier) -> "SignalIdentifier": + return cls( + signal_id=proto.signal_id, + execution_id=WorkflowExecutionIdentifier.from_flyte_idl(proto.execution_id), + ) diff --git a/flytekit/flytekit/models/core/types.py b/flytekit/flytekit/models/core/types.py new file mode 100644 index 0000000000..4508961bbc --- /dev/null +++ b/flytekit/flytekit/models/core/types.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import typing + +from flyteidl.core import types_pb2 as _types_pb2 + +from flytekit.models import common as _common + + +class EnumType(_common.FlyteIdlEntity): + """ + Models _types_pb2.EnumType + """ + + def __init__(self, values: typing.List[str]): + self._values = values + + @property + def values(self) -> typing.List[str]: + return self._values + + def to_flyte_idl(self) -> _types_pb2.EnumType: + return _types_pb2.EnumType( + values=self._values if self._values else [], + ) + + @classmethod + def from_flyte_idl(cls, proto: _types_pb2.EnumType): + return cls(values=proto.values) + + +class BlobType(_common.FlyteIdlEntity): + """ + This type represents offloaded data and is typically used for things like files. + """ + + class BlobDimensionality(object): + SINGLE = _types_pb2.BlobType.SINGLE + MULTIPART = _types_pb2.BlobType.MULTIPART + + def __init__(self, format, dimensionality): + """ + :param Text format: A string describing the format of the underlying blob data. + :param int dimensionality: An integer from BlobType.BlobDimensionality enum + """ + self._format = format + self._dimensionality = dimensionality + + @property + def format(self): + """ + A string describing the format of the underlying blob data. + :rtype: Text + """ + return self._format + + @property + def dimensionality(self): + """ + An integer from BlobType.BlobDimensionality enum + :rtype: int + """ + return self._dimensionality + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.types_pb2.BlobType + """ + return _types_pb2.BlobType(format=self.format, dimensionality=self.dimensionality) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.types_pb2.BlobType proto: + :rtype: BlobType + """ + return cls(format=proto.format, dimensionality=proto.dimensionality) diff --git a/flytekit/flytekit/models/core/workflow.py b/flytekit/flytekit/models/core/workflow.py new file mode 100644 index 0000000000..62636d1420 --- /dev/null +++ b/flytekit/flytekit/models/core/workflow.py @@ -0,0 +1,1006 @@ +import datetime +import typing + +from flyteidl.core import tasks_pb2 +from flyteidl.core import workflow_pb2 as _core_workflow + +from flytekit.models import common as _common +from flytekit.models import interface as _interface +from flytekit.models import types as type_models +from flytekit.models.core import condition as _condition +from flytekit.models.core import identifier as _identifier +from flytekit.models.literals import Binding as _Binding +from flytekit.models.literals import RetryStrategy as _RetryStrategy +from flytekit.models.task import Resources + + +class IfBlock(_common.FlyteIdlEntity): + def __init__(self, condition, then_node): + """ + Defines a condition and the execution unit that should be executed if the condition is satisfied. + + :param flytekit.models.core.condition.BooleanExpression condition: + :param Node then_node: + """ + + self._condition = condition + self._then_node = then_node + + @property + def condition(self): + """ + :rtype: flytekit.models.core.condition.BooleanExpression + """ + return self._condition + + @property + def then_node(self): + """ + :rtype: Node + """ + return self._then_node + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.IfBlock + """ + return _core_workflow.IfBlock(condition=self.condition.to_flyte_idl(), then_node=self.then_node.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + condition=_condition.BooleanExpression.from_flyte_idl(pb2_object.condition), + then_node=Node.from_flyte_idl(pb2_object.then_node), + ) + + +class IfElseBlock(_common.FlyteIdlEntity): + def __init__(self, case, other=None, else_node=None, error=None): + """ + Defines a series of if/else blocks. The first branch whose condition evaluates to true is the one to execute. + If no conditions were satisfied, the else_node or the error will execute. + + :param IfBlock case: + :param list[IfBlock] other: + :param Node else_node: + :param type_models.Error error: + """ + self._case = case + self._other = other + self._else_node = else_node + self._error = error + + @property + def case(self): + """ + First condition to evaluate. + + :rtype: IfBlock + """ + + return self._case + + @property + def other(self): + """ + Additional branches to evaluate. + + :rtype: list[IfBlock] + """ + + return self._other + + @property + def else_node(self): + """ + The node to execute in case none of the branches were taken. + + :rtype: Node + """ + + return self._else_node + + @property + def error(self): + """ + An error to throw in case none of the branches were taken. + + :rtype: flytekit.models.types.Error + """ + + return self._error + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.IfElseBlock + """ + return _core_workflow.IfElseBlock( + case=self.case.to_flyte_idl(), + other=[a.to_flyte_idl() for a in self.other] if self.other else None, + else_node=self.else_node.to_flyte_idl() if self.else_node else None, + error=self.error.to_flyte_idl() if self.error else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + case=IfBlock.from_flyte_idl(pb2_object.case), + other=[IfBlock.from_flyte_idl(a) for a in pb2_object.other], + else_node=Node.from_flyte_idl(pb2_object.else_node) if pb2_object.HasField("else_node") else None, + error=type_models.Error.from_flyte_idl(pb2_object.error) if pb2_object.HasField("error") else None, + ) + + +class BranchNode(_common.FlyteIdlEntity): + def __init__(self, if_else: IfElseBlock): + """ + BranchNode is a special node that alter the flow of the workflow graph. It allows the control flow to branch at + runtime based on a series of conditions that get evaluated on various parameters (e.g. inputs, primtives). + + :param IfElseBlock if_else: + """ + + self._if_else = if_else + + @property + def if_else(self) -> IfElseBlock: + """ + :rtype: IfElseBlock + """ + + return self._if_else + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.BranchNode + """ + return _core_workflow.BranchNode(if_else=self.if_else.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_objct): + return cls(if_else=IfElseBlock.from_flyte_idl(pb2_objct.if_else)) + + +class NodeMetadata(_common.FlyteIdlEntity): + def __init__( + self, + name, + timeout=None, + retries=None, + interruptible: typing.Optional[bool] = None, + cacheable: typing.Optional[bool] = None, + cache_version: typing.Optional[str] = None, + cache_serializable: typing.Optional[bool] = None, + ): + """ + Defines extra information about the Node. + + :param Text name: Friendly name for the Node. + :param datetime.timedelta timeout: [Optional] Overall timeout for a task. + :param flytekit.models.literals.RetryStrategy retries: [Optional] Number of retries per task. + :param bool interruptible: Can be safely interrupted during execution. + :param cacheable: Indicates that this nodes outputs should be cached. + :param cache_version: The version of the cached data. + :param cacheable: Indicates that cache operations on this node should be serialized. + """ + self._name = name + self._timeout = timeout if timeout is not None else datetime.timedelta() + self._retries = retries if retries is not None else _RetryStrategy(0) + self._interruptible = interruptible + self._cacheable = cacheable + self._cache_version = cache_version + self._cache_serializable = cache_serializable + + @property + def name(self): + """ + :rtype: Text + """ + return self._name + + @property + def timeout(self): + """ + :rtype: datetime.timedelta + """ + return self._timeout + + @property + def retries(self): + """ + :rtype: flytekit.models.literals.RetryStrategy + """ + return self._retries + + @property + def interruptible(self) -> typing.Optional[bool]: + return self._interruptible + + @property + def cacheable(self) -> typing.Optional[bool]: + return self._cacheable + + @property + def cache_version(self) -> typing.Optional[str]: + return self._cache_version + + @property + def cache_serializable(self) -> typing.Optional[bool]: + return self._cache_serializable + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.NodeMetadata + """ + node_metadata = _core_workflow.NodeMetadata( + name=self.name, + retries=self.retries.to_flyte_idl(), + interruptible=self.interruptible, + cacheable=self.cacheable, + cache_version=self.cache_version, + cache_serializable=self.cache_serializable, + ) + if self.timeout: + node_metadata.timeout.FromTimedelta(self.timeout) + return node_metadata + + @classmethod + def from_flyte_idl(cls, pb2_object): + return cls( + pb2_object.name, + pb2_object.timeout.ToTimedelta(), + _RetryStrategy.from_flyte_idl(pb2_object.retries), + pb2_object.interruptible if pb2_object.HasField("interruptible") else None, + pb2_object.cacheable if pb2_object.HasField("cacheable") else None, + pb2_object.cache_version if pb2_object.HasField("cache_version") else None, + pb2_object.cache_serializable if pb2_object.HasField("cache_serializable") else None, + ) + + +class SignalCondition(_common.FlyteIdlEntity): + def __init__(self, signal_id: str, type: type_models.LiteralType, output_variable_name: str): + """ + Represents a dependency on an signal from a user. + + :param signal_id: The node id of the signal, also the signal name. + :param type: + """ + self._signal_id = signal_id + self._type = type + self._output_variable_name = output_variable_name + + @property + def signal_id(self) -> str: + return self._signal_id + + @property + def type(self) -> type_models.LiteralType: + return self._type + + @property + def output_variable_name(self) -> str: + return self._output_variable_name + + def to_flyte_idl(self) -> _core_workflow.SignalCondition: + return _core_workflow.SignalCondition( + signal_id=self.signal_id, type=self.type.to_flyte_idl(), output_variable_name=self.output_variable_name + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_workflow.SignalCondition): + return cls( + signal_id=pb2_object.signal_id, + type=type_models.LiteralType.from_flyte_idl(pb2_object.type), + output_variable_name=pb2_object.output_variable_name, + ) + + +class ApproveCondition(_common.FlyteIdlEntity): + def __init__(self, signal_id: str): + """ + Represents a dependency on an signal from a user. + + :param signal_id: The node id of the signal, also the signal name. + """ + self._signal_id = signal_id + + @property + def signal_id(self) -> str: + return self._signal_id + + def to_flyte_idl(self) -> _core_workflow.ApproveCondition: + return _core_workflow.ApproveCondition(signal_id=self.signal_id) + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_workflow.ApproveCondition): + return cls(signal_id=pb2_object.signal_id) + + +class SleepCondition(_common.FlyteIdlEntity): + def __init__(self, duration: datetime.timedelta): + """ + A sleep condition. + """ + self._duration = duration + + @property + def duration(self) -> datetime.timedelta: + return self._duration + + def to_flyte_idl(self) -> _core_workflow.SleepCondition: + sc = _core_workflow.SleepCondition() + sc.duration.FromTimedelta(self.duration) + return sc + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_workflow.SignalCondition) -> "SleepCondition": + return cls(duration=pb2_object.duration.ToTimedelta()) + + +class GateNode(_common.FlyteIdlEntity): + def __init__( + self, + signal: typing.Optional[SignalCondition] = None, + sleep: typing.Optional[SleepCondition] = None, + approve: typing.Optional[ApproveCondition] = None, + ): + self._signal = signal + self._sleep = sleep + self._approve = approve + + @property + def signal(self) -> typing.Optional[SignalCondition]: + return self._signal + + @property + def sleep(self) -> typing.Optional[SignalCondition]: + return self._sleep + + @property + def approve(self) -> typing.Optional[ApproveCondition]: + return self._approve + + @property + def condition(self) -> typing.Union[SignalCondition, SleepCondition, ApproveCondition]: + return self.signal or self.sleep or self.approve + + def to_flyte_idl(self) -> _core_workflow.GateNode: + return _core_workflow.GateNode( + signal=self.signal.to_flyte_idl() if self.signal else None, + sleep=self.sleep.to_flyte_idl() if self.sleep else None, + approve=self.approve.to_flyte_idl() if self.approve else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_workflow.GateNode) -> "GateNode": + return cls( + signal=SignalCondition.from_flyte_idl(pb2_object.signal) if pb2_object.HasField("signal") else None, + sleep=SleepCondition.from_flyte_idl(pb2_object.sleep) if pb2_object.HasField("sleep") else None, + approve=ApproveCondition.from_flyte_idl(pb2_object.approve) if pb2_object.HasField("approve") else None, + ) + + +class ArrayNode(_common.FlyteIdlEntity): + def __init__(self, node: "Node", parallelism=None, min_successes=None, min_success_ratio=None) -> None: + """ + TODO: docstring + """ + self._node = node + self._parallelism = parallelism + # TODO either min_successes or min_success_ratio should be set + self._min_successes = min_successes + self._min_success_ratio = min_success_ratio + + @property + def node(self) -> "Node": + return self._node + + def to_flyte_idl(self) -> _core_workflow.ArrayNode: + return _core_workflow.ArrayNode( + node=self._node.to_flyte_idl() if self._node is not None else None, + parallelism=self._parallelism, + min_successes=self._min_successes, + min_success_ratio=self._min_success_ratio, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object) -> "ArrayNode": + return cls( + Node.from_flyte_idl(pb2_object.node), + pb2_object.parallelism, + pb2_object.min_successes, + pb2_object.min_success_ratio, + ) + + +class Node(_common.FlyteIdlEntity): + def __init__( + self, + id, + metadata, + inputs, + upstream_node_ids, + output_aliases, + task_node=None, + workflow_node=None, + branch_node=None, + gate_node: typing.Optional[GateNode] = None, + array_node: typing.Optional[ArrayNode] = None, + ): + """ + A Workflow graph Node. One unit of execution in the graph. Each node can be linked to a Task, + a Workflow or a branch node. One of the nodes must be specified. + + :param Text id: A workflow-level unique identifier that identifies this node in the workflow. "inputs" and + "outputs" are reserved node ids that cannot be used by other nodes. + :param NodeMetadata metadata: Extra metadata about the node. + :param list[flytekit.models.literals.Binding] inputs: Specifies how to bind the underlying + interface's inputs. All required inputs specified in the underlying interface must be fulfilled. + :param list[Text] upstream_node_ids: Specifies execution dependency for this node ensuring it will + only get scheduled to run after all its upstream nodes have completed. This node will have + an implicit dependency on any node that appears in inputs field. + :param list[Alias] output_aliases: A node can define aliases for a subset of its outputs. This + is particularly useful if different nodes need to conform to the same interface (e.g. all branches in + a branch node). Downstream nodes must refer to this node's outputs using the alias if one is specified. + :param TaskNode task_node: [Optional] Information about the Task to execute in this node. + :param WorkflowNode workflow_node: [Optional] Information about the Workflow to execute in this mode. + :param BranchNode branch_node: [Optional] Information about the branch node to evaluate in this node. + """ + + self._id = id + self._metadata = metadata + self._inputs = inputs + self._upstream_node_ids = upstream_node_ids + # TODO: For proper graph handling, we need to keep track of the node objects themselves, not just the node IDs + self._output_aliases = output_aliases + self._task_node = task_node + self._workflow_node = workflow_node + self._branch_node = branch_node + self._gate_node = gate_node + self._array_node = array_node + + @property + def id(self): + """ + A workflow-level unique identifier that identifies this node in the workflow. "inputs" and + "outputs" are reserved node ids that cannot be used by other nodes. + + :rtype: Text + """ + return self._id + + @property + def metadata(self): + """ + Extra metadata about the node. + + :rtype: NodeMetadata + """ + return self._metadata + + @property + def inputs(self): + """ + Specifies how to bind the underlying interface's inputs. All required inputs specified + in the underlying interface must be fulfilled. + + :rtype: list[flytekit.models.literals.Binding] + """ + return self._inputs + + @property + def upstream_node_ids(self): + """ + [Optional] Specifies execution dependency for this node ensuring it will + only get scheduled to run after all its upstream nodes have completed. This node will have + an implicit dependency on any node that appears in inputs field. + + :rtype: list[Text] + """ + return self._upstream_node_ids + + @property + def output_aliases(self): + """ + [Optional] A node can define aliases for a subset of its outputs. This + is particularly useful if different nodes need to conform to the same interface (e.g. all branches in + a branch node). Downstream nodes must refer to this node's outputs using the alias if one is specified. + + :rtype: list[Alias] + """ + return self._output_aliases + + @property + def task_node(self): + """ + [Optional] Information about the Task to execute in this node. + + :rtype: TaskNode + """ + return self._task_node + + @property + def workflow_node(self): + """ + [Optional] Information about the Workflow to execute in this mode. + + :rtype: WorkflowNode + """ + return self._workflow_node + + @property + def branch_node(self): + """ + [Optional] Information about the branch node to evaluate in this node. + + :rtype: BranchNode + """ + return self._branch_node + + @property + def gate_node(self) -> typing.Optional[GateNode]: + return self._gate_node + + @property + def array_node(self) -> typing.Optional[ArrayNode]: + return self._array_node + + @property + def target(self): + """ + :rtype: T + """ + return self.task_node or self.workflow_node or self.branch_node + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.Node + """ + return _core_workflow.Node( + id=self.id, + metadata=self.metadata.to_flyte_idl() if self.metadata is not None else None, + inputs=[i.to_flyte_idl() for i in self.inputs], + upstream_node_ids=self.upstream_node_ids, + output_aliases=[a.to_flyte_idl() for a in self.output_aliases], + task_node=self.task_node.to_flyte_idl() if self.task_node is not None else None, + workflow_node=self.workflow_node.to_flyte_idl() if self.workflow_node is not None else None, + branch_node=self.branch_node.to_flyte_idl() if self.branch_node is not None else None, + gate_node=self.gate_node.to_flyte_idl() if self.gate_node else None, + array_node=self.array_node.to_flyte_idl() if self.array_node else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.Node pb2_object: + :rtype: Node + """ + return cls( + id=pb2_object.id, + metadata=NodeMetadata.from_flyte_idl(pb2_object.metadata), + inputs=[_Binding.from_flyte_idl(b) for b in pb2_object.inputs], + upstream_node_ids=pb2_object.upstream_node_ids, + output_aliases=[Alias.from_flyte_idl(a) for a in pb2_object.output_aliases], + task_node=TaskNode.from_flyte_idl(pb2_object.task_node) if pb2_object.HasField("task_node") else None, + workflow_node=WorkflowNode.from_flyte_idl(pb2_object.workflow_node) + if pb2_object.HasField("workflow_node") + else None, + branch_node=BranchNode.from_flyte_idl(pb2_object.branch_node) + if pb2_object.HasField("branch_node") + else None, + gate_node=GateNode.from_flyte_idl(pb2_object.gate_node) if pb2_object.HasField("gate_node") else None, + array_node=ArrayNode.from_flyte_idl(pb2_object.array_node) if pb2_object.HasField("array_node") else None, + ) + + +class TaskNodeOverrides(_common.FlyteIdlEntity): + def __init__( + self, resources: typing.Optional[Resources], extended_resources: typing.Optional[tasks_pb2.ExtendedResources] + ): + self._resources = resources + self._extended_resources = extended_resources + + @property + def resources(self) -> Resources: + return self._resources + + @property + def extended_resources(self) -> tasks_pb2.ExtendedResources: + return self._extended_resources + + def to_flyte_idl(self): + return _core_workflow.TaskNodeOverrides( + resources=self.resources.to_flyte_idl() if self.resources is not None else None, + extended_resources=self.extended_resources, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + resources = Resources.from_flyte_idl(pb2_object.resources) + extended_resources = pb2_object.extended_resources if pb2_object.HasField("extended_resources") else None + if bool(resources.requests) or bool(resources.limits): + return cls(resources=resources, extended_resources=extended_resources) + return cls(resources=None, extended_resources=extended_resources) + + +class TaskNode(_common.FlyteIdlEntity): + def __init__(self, reference_id, overrides: typing.Optional[TaskNodeOverrides] = None): + """ + Refers to the task that the Node is to execute. + This is currently a oneof in protobuf, but there's only one option currently. + This code should be updated when more options are available. + + :param flytekit.models.core.identifier.Identifier reference_id: A globally unique identifier for the task. + :param flyteidl.core.workflow_pb2.TaskNodeOverrides: + """ + self._reference_id = reference_id + self._overrides = overrides + + @property + def reference_id(self): + """ + A globally unique identifier for the task. This should map to the identifier in Flyte Admin. + + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._reference_id + + @property + def overrides(self) -> TaskNodeOverrides: + return self._overrides + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.TaskNode + """ + return _core_workflow.TaskNode( + reference_id=self.reference_id.to_flyte_idl(), + overrides=self.overrides.to_flyte_idl() if self.overrides is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.TaskNode pb2_object: + :rtype: TaskNode + """ + overrides = TaskNodeOverrides.from_flyte_idl(pb2_object.overrides) + if overrides.resources is None: + overrides = None + return cls( + reference_id=_identifier.Identifier.from_flyte_idl(pb2_object.reference_id), + overrides=overrides, + ) + + +class WorkflowNode(_common.FlyteIdlEntity): + def __init__(self, launchplan_ref=None, sub_workflow_ref=None): + """ + Refers to a the workflow the node is to execute. One of the references must be supplied. + + :param flytekit.models.core.identifier.Identifier launchplan_ref: [Optional] A globally unique identifier for + the launch plan. Should map to Admin. + :param flytekit.models.core.identifier.Identifier sub_workflow_ref: [Optional] Reference to a subworkflow, + that should be defined with the compiler context. + """ + self._launchplan_ref = launchplan_ref + self._sub_workflow_ref = sub_workflow_ref + + @property + def launchplan_ref(self): + """ + [Optional] A globally unique identifier for the launch plan. Should map to Admin. + + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._launchplan_ref + + @property + def sub_workflow_ref(self): + """ + [Optional] Reference to a subworkflow, that should be defined with the compiler context. + + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._sub_workflow_ref + + @property + def reference(self): + """ + :rtype: flytekit.models.core.identifier.Identifier + """ + return self.launchplan_ref or self.sub_workflow_ref + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.WorkflowNode + """ + return _core_workflow.WorkflowNode( + launchplan_ref=self.launchplan_ref.to_flyte_idl() if self.launchplan_ref else None, + sub_workflow_ref=self.sub_workflow_ref.to_flyte_idl() if self.sub_workflow_ref else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.WorkflowNode pb2_object: + + :rtype: WorkflowNode + """ + if pb2_object.HasField("launchplan_ref"): + return cls(launchplan_ref=_identifier.Identifier.from_flyte_idl(pb2_object.launchplan_ref)) + else: + return cls(sub_workflow_ref=_identifier.Identifier.from_flyte_idl(pb2_object.sub_workflow_ref)) + + +class WorkflowMetadata(_common.FlyteIdlEntity): + class OnFailurePolicy(object): + """ + Defines the execution behavior of the workflow when a failure is detected. + + Attributes: + FAIL_IMMEDIATELY Instructs the system to fail as soon as a node fails in the + workflow. It'll automatically abort all currently running nodes and + clean up resources before finally marking the workflow executions as failed. + + FAIL_AFTER_EXECUTABLE_NODES_COMPLETE Instructs the system to make as much progress as it can. The system + will not alter the dependencies of the execution graph so any node + that depend on the failed node will not be run. Other nodes that will + be executed to completion before cleaning up resources and marking + the workflow execution as failed. + """ + + FAIL_IMMEDIATELY = _core_workflow.WorkflowMetadata.FAIL_IMMEDIATELY + FAIL_AFTER_EXECUTABLE_NODES_COMPLETE = _core_workflow.WorkflowMetadata.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE + + def __init__(self, on_failure=None): + """ + Metadata for the workflow. + + :param on_failure flytekit.models.core.workflow.WorkflowMetadata.OnFailurePolicy: [Optional] The execution policy when the workflow detects a failure. + """ + self._on_failure = on_failure + + @property + def on_failure(self): + """ + :rtype: flytekit.models.core.workflow.WorkflowMetadata.OnFailurePolicy + """ + return self._on_failure + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.WorkflowMetadata + """ + workflow_metadata = _core_workflow.WorkflowMetadata() + if self.on_failure: + workflow_metadata.on_failure = self.on_failure + return workflow_metadata + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.WorkflowMetadata pb2_object: + + :rtype: WorkflowMetadata + """ + return cls( + on_failure=pb2_object.on_failure + if pb2_object.on_failure + else WorkflowMetadata.OnFailurePolicy.FAIL_IMMEDIATELY + ) + + +class WorkflowMetadataDefaults(_common.FlyteIdlEntity): + def __init__(self, interruptible=None): + """ + Metadata Defaults for the workflow. + """ + self._interruptible = interruptible + + @property + def interruptible(self): + return self._interruptible + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.WorkflowMetadataDefaults + """ + return _core_workflow.WorkflowMetadataDefaults(interruptible=self._interruptible) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.WorkflowMetadataDefaults pb2_object: + + :rtype: WorkflowMetadata + """ + return cls(interruptible=pb2_object.interruptible) + + +class WorkflowTemplate(_common.FlyteIdlEntity): + def __init__( + self, + id, + metadata, + metadata_defaults, + interface, + nodes, + outputs, + failure_node=None, + ): + """ + A workflow template encapsulates all the task, branch, and subworkflow nodes to run a statically analyzable, + directed acyclic graph. It contains also metadata that tells the system how to execute the workflow (i.e. + the AWS IAM role to run with). + + :param flytekit.models.core.identifier.Identifier id: This is an autogenerated id by the system. The id is + globally unique across Flyte. + :param WorkflowMetadata metadata: This contains information on how to run the workflow. + :param WorkflowMetadataDefaults metadata_defaults: This contains the default information on how to run the workflow. + :param flytekit.models.interface.TypedInterface interface: Defines a strongly typed interface for the + Workflow (inputs, outputs). This can include some optional parameters. + :param list[Node] nodes: A list of nodes. In addition, "globals" is a special reserved node id that + can be used to consume workflow inputs + :param list[flytekit.models.literals.Binding] outputs: A list of output bindings that specify how to construct + workflow outputs. Bindings can pull node outputs or specify literals. All workflow outputs specified in + the interface field must be bound + in order for the workflow to be validated. A workflow has an implicit dependency on all of its nodes + to execute successfully in order to bind final outputs. + :param Node failure_node: [Optional] A catch-all node. This node is executed whenever the execution + engine determines the workflow has failed. The interface of this node must match the Workflow interface + with an additional input named "error" of type pb.lyft.flyte.core.Error. + """ + self._id = id + self._metadata = metadata + self._metadata_defaults = metadata_defaults + self._interface = interface + self._nodes = nodes + self._outputs = outputs + self._failure_node = failure_node + + @property + def id(self): + """ + This is an autogenerated id by the system. The id is globally unique across Flyte. + + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._id + + @property + def metadata(self): + """ + This contains information on how to run the workflow. + + :rtype: WorkflowMetadata + """ + return self._metadata + + @property + def metadata_defaults(self): + """ + This contains information on how to run the workflow. + + :rtype: WorkflowMetadataDefaults + """ + return self._metadata_defaults + + @property + def interface(self): + """ + Defines a strongly typed interface for the Workflow (inputs, outputs). This can include some optional + parameters. + + :rtype: flytekit.models.interface.TypedInterface + """ + return self._interface + + @property + def nodes(self): + """ + A list of nodes. In addition, "globals" is a special reserved node id that can be used to consume + workflow inputs. + + :rtype: list[Node] + """ + return self._nodes + + @property + def outputs(self): + """ + A list of output bindings that specify how to construct workflow outputs. Bindings can + pull node outputs or specify literals. All workflow outputs specified in the interface field must be bound + in order for the workflow to be validated. A workflow has an implicit dependency on all of its nodes + to execute successfully in order to bind final outputs. + + :rtype: list[flytekit.models.literals.Binding] + """ + return self._outputs + + @property + def failure_node(self): + """ + Node failure_node: A catch-all node. This node is executed whenever the execution engine determines the + workflow has failed. The interface of this node must match the Workflow interface with an additional input + named "error" of type pb.lyft.flyte.core.Error. + + :rtype: Node + """ + return self._failure_node + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.WorkflowTemplate + """ + return _core_workflow.WorkflowTemplate( + id=self.id.to_flyte_idl(), + metadata=self.metadata.to_flyte_idl(), + metadata_defaults=self.metadata_defaults.to_flyte_idl(), + interface=self.interface.to_flyte_idl(), + nodes=[n.to_flyte_idl() for n in self.nodes], + outputs=[o.to_flyte_idl() for o in self.outputs], + failure_node=self.failure_node.to_flyte_idl() if self.failure_node is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.WorkflowTemplate pb2_object: + + :rtype: WorkflowTemplate + """ + return cls( + id=_identifier.Identifier.from_flyte_idl(pb2_object.id), + metadata=WorkflowMetadata.from_flyte_idl(pb2_object.metadata), + metadata_defaults=WorkflowMetadataDefaults.from_flyte_idl(pb2_object.metadata_defaults), + interface=_interface.TypedInterface.from_flyte_idl(pb2_object.interface), + nodes=[Node.from_flyte_idl(n) for n in pb2_object.nodes], + outputs=[_Binding.from_flyte_idl(b) for b in pb2_object.outputs], + failure_node=Node.from_flyte_idl(pb2_object.failure_node) if pb2_object.HasField("failure_node") else None, + ) + + +class Alias(_common.FlyteIdlEntity): + def __init__(self, var, alias): + """ + Links a variable to an alias. + + :param Text var: Must match one of the output variable names on a node. + :param Text alias: A workflow-level unique alias that downstream nodes can refer to in their input. + """ + self._var = var + self._alias = alias + + @property + def var(self): + """ + Must match one of the output variable names on a node. + + :rtype: Text + """ + return self._var + + @property + def alias(self): + """ + A workflow-level unique alias that downstream nodes can refer to in their input. + + :rtype: Text + """ + return self._alias + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_pb2.Alias + """ + return _core_workflow.Alias(var=self.var, alias=self.alias) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_pb2.Alias pb2_object: + + :return: Alias + """ + return cls(pb2_object.var, pb2_object.alias) diff --git a/flytekit/flytekit/models/documentation.py b/flytekit/flytekit/models/documentation.py new file mode 100644 index 0000000000..e1bae8122e --- /dev/null +++ b/flytekit/flytekit/models/documentation.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from flyteidl.admin import description_entity_pb2 + +from flytekit.models import common as _common_models + + +@dataclass +class Description(_common_models.FlyteIdlEntity): + """ + Full user description with formatting preserved. This can be rendered + by clients, such as the console or command line tools with in-tact + formatting. + """ + + class DescriptionFormat(Enum): + UNKNOWN = 0 + MARKDOWN = 1 + HTML = 2 + RST = 3 + + value: Optional[str] = None + uri: Optional[str] = None + icon_link: Optional[str] = None + format: DescriptionFormat = DescriptionFormat.RST + + def to_flyte_idl(self): + return description_entity_pb2.Description( + value=self.value if self.value else None, + uri=self.uri if self.uri else None, + format=self.format.value, + icon_link=self.icon_link, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: description_entity_pb2.Description) -> "Description": + return cls( + value=pb2_object.value if pb2_object.value else None, + uri=pb2_object.uri if pb2_object.uri else None, + format=Description.DescriptionFormat(pb2_object.format), + icon_link=pb2_object.icon_link if pb2_object.icon_link else None, + ) + + +@dataclass +class SourceCode(_common_models.FlyteIdlEntity): + """ + Link to source code used to define this task or workflow. + """ + + link: Optional[str] = None + + def to_flyte_idl(self): + return description_entity_pb2.SourceCode(link=self.link) + + @classmethod + def from_flyte_idl(cls, pb2_object: description_entity_pb2.SourceCode) -> "SourceCode": + return cls(link=pb2_object.link) if pb2_object.link else None + + +@dataclass +class Documentation(_common_models.FlyteIdlEntity): + """ + DescriptionEntity contains detailed description for the task/workflow/launch plan. + Documentation could provide insight into the algorithms, business use case, etc. + Args: + short_description (str): One-liner overview of the entity. + long_description (Optional[Description]): Full user description with formatting preserved. + source_code (Optional[SourceCode]): link to source code used to define this entity + """ + + short_description: Optional[str] = None + long_description: Optional[Description] = None + source_code: Optional[SourceCode] = None + + def to_flyte_idl(self): + return description_entity_pb2.DescriptionEntity( + short_description=self.short_description, + long_description=self.long_description.to_flyte_idl() if self.long_description else None, + source_code=self.source_code.to_flyte_idl() if self.source_code else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: description_entity_pb2.DescriptionEntity) -> "Documentation": + return cls( + short_description=pb2_object.short_description, + long_description=Description.from_flyte_idl(pb2_object.long_description) + if pb2_object.long_description + else None, + source_code=SourceCode.from_flyte_idl(pb2_object.source_code) if pb2_object.source_code else None, + ) diff --git a/flytekit/flytekit/models/dynamic_job.py b/flytekit/flytekit/models/dynamic_job.py new file mode 100644 index 0000000000..44a985a5e7 --- /dev/null +++ b/flytekit/flytekit/models/dynamic_job.py @@ -0,0 +1,99 @@ +from flyteidl.core import dynamic_job_pb2 as _dynamic_job + +from flytekit.models import common as _common +from flytekit.models import literals as _literals +from flytekit.models import task as _task +from flytekit.models.core import workflow as _workflow + + +class DynamicJobSpec(_common.FlyteIdlEntity): + def __init__(self, tasks, nodes, min_successes, outputs, subworkflows): + """ + Initializes a new FutureTaskDocument. + + :param list[flytekit.models.task.TaskTemplate] tasks: A collection of unique tasks to execute. + :param list[flytekit.models.core.workflow.Node] nodes: A collection of task nodes. + :param int min_successes: An absolute number of the minimum number of successful completions of subtasks. As + soon as this criteria is met, the future job will be marked as successful and outputs will be computed. + :param list[flytekit.models.literals.Binding] outputs: Describes how to bind the final output of the future + task from the + outputs of executed nodes. The referenced ids in bindings should have the generated id for the subtask. + :param list[flytekit.models.workflow.WorkflowTemplate] subworkflows: A complete list of task specs referenced + in nodes. + """ + + self._tasks = tasks + self._nodes = nodes + self._min_successes = min_successes + self._outputs = outputs + self._subworkflows = subworkflows + + @property + def tasks(self): + """ + A collection of tasks to execute. + :rtype: list[_task.TaskTemplate] + """ + return self._tasks + + @property + def nodes(self): + """ + A collection of dynamic nodes. + :rtype: list[_workflow.Node] + """ + return self._nodes + + @property + def min_successes(self): + """ + An absolute number of the minimum number of successful completions of subtasks. As + soon as this criteria is met, the future job will be marked as successful and outputs will be computed. + :rtype: int + """ + return self._min_successes + + @property + def outputs(self): + """ + Describes how to bind the final output of the future task from the outputs of executed nodes. + The referenced ids in bindings should have the generated id for the subtask. + :rtype: list[flytekit.models.literals.Binding] + """ + return self._outputs + + @property + def subworkflows(self): + """ + A collection of subworkflows to execute. + :rtype: list[flytekit.models.core.workflow.WorkflowTemplate] + """ + return self._subworkflows + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.dynamic_job.DynamicJobSpec + """ + return _dynamic_job.DynamicJobSpec( + tasks=[task.to_flyte_idl() for task in self.tasks] if self.tasks else None, + nodes=[node.to_flyte_idl() for node in self.nodes] if self.nodes else None, + min_successes=self.min_successes, + outputs=[output.to_flyte_idl() for output in self.outputs], + subworkflows=[workflow.to_flyte_idl() for workflow in self.subworkflows], + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.dynamic_job_pb2.DynamicJobSpec pb2_object: + :return: DynamicJobSpec + """ + return cls( + tasks=[_task.TaskTemplate.from_flyte_idl(task) for task in pb2_object.tasks] if pb2_object.tasks else None, + nodes=[_workflow.Node.from_flyte_idl(n) for n in pb2_object.nodes], + min_successes=pb2_object.min_successes, + outputs=[_literals.Binding.from_flyte_idl(output) for output in pb2_object.outputs] + if pb2_object.outputs + else None, + subworkflows=[_workflow.WorkflowTemplate.from_flyte_idl(w) for w in pb2_object.subworkflows], + ) diff --git a/flytekit/flytekit/models/execution.py b/flytekit/flytekit/models/execution.py new file mode 100644 index 0000000000..11c0f547d7 --- /dev/null +++ b/flytekit/flytekit/models/execution.py @@ -0,0 +1,781 @@ +from __future__ import annotations + +import datetime +import typing +from datetime import timezone as _timezone +from typing import Optional + +import flyteidl +import flyteidl.admin.cluster_assignment_pb2 as _cluster_assignment_pb2 +import flyteidl.admin.execution_pb2 as _execution_pb2 +import flyteidl.admin.node_execution_pb2 as _node_execution_pb2 +import flyteidl.admin.task_execution_pb2 as _task_execution_pb2 + +import flytekit +from flytekit.models import common as _common_models +from flytekit.models import literals as _literals_models +from flytekit.models import security +from flytekit.models.core import execution as _core_execution +from flytekit.models.core import identifier as _identifier +from flytekit.models.node_execution import DynamicWorkflowNodeMetadata + + +class SystemMetadata(_common_models.FlyteIdlEntity): + def __init__(self, execution_cluster: str): + self._execution_cluster = execution_cluster + + @property + def execution_cluster(self) -> str: + return self._execution_cluster + + def to_flyte_idl(self) -> flyteidl.admin.execution_pb2.SystemMetadata: + return _execution_pb2.SystemMetadata(execution_cluster=self.execution_cluster) + + @classmethod + def from_flyte_idl(cls, pb2_object: flyteidl.admin.execution_pb2.SystemMetadata) -> SystemMetadata: + return cls( + execution_cluster=pb2_object.execution_cluster, + ) + + +class ExecutionMetadata(_common_models.FlyteIdlEntity): + class ExecutionMode(object): + MANUAL = 0 + SCHEDULED = 1 + SYSTEM = 2 + + def __init__( + self, + mode: int, + principal: str, + nesting: int, + scheduled_at: Optional[datetime.datetime] = None, + parent_node_execution: Optional[_identifier.NodeExecutionIdentifier] = None, + reference_execution: Optional[_identifier.WorkflowExecutionIdentifier] = None, + system_metadata: Optional[SystemMetadata] = None, + ): + """ + :param mode: An enum value from ExecutionMetadata.ExecutionMode which specifies how the job started. + :param principal: The entity that triggered the execution + :param nesting: An integer representing how deeply nested the workflow is (i.e. was it triggered by a parent + workflow) + :param scheduled_at: For scheduled executions, the requested time for execution for this specific schedule invocation. + :param parent_node_execution: Which subworkflow node (if any) launched this execution + :param reference_execution: Optional, reference workflow execution related to this execution + :param system_metadata: Optional, platform-specific metadata about the execution. + """ + self._mode = mode + self._principal = principal + self._nesting = nesting + self._scheduled_at = scheduled_at + self._parent_node_execution = parent_node_execution + self._reference_execution = reference_execution + self._system_metadata = system_metadata + + @property + def mode(self) -> int: + """ + An enum value from ExecutionMetadata.ExecutionMode which specifies how the job started. + """ + return self._mode + + @property + def principal(self) -> str: + """ + The entity that triggered the execution + """ + return self._principal + + @property + def nesting(self) -> int: + """ + An integer representing how deeply nested the workflow is (i.e. was it triggered by a parent workflow) + """ + return self._nesting + + @property + def scheduled_at(self) -> datetime.datetime: + """ + For scheduled executions, the requested time for execution for this specific schedule invocation. + """ + return self._scheduled_at + + @property + def parent_node_execution(self) -> _identifier.NodeExecutionIdentifier: + """ + Which subworkflow node (if any) launched this execution + """ + return self._parent_node_execution + + @property + def reference_execution(self) -> _identifier.WorkflowExecutionIdentifier: + """ + Optional, reference workflow execution related to this execution + """ + return self._reference_execution + + @property + def system_metadata(self) -> SystemMetadata: + """ + Optional, platform-specific metadata about the execution. + """ + return self._system_metadata + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.execution_pb2.ExecutionMetadata + """ + p = _execution_pb2.ExecutionMetadata( + mode=self.mode, + principal=self.principal, + nesting=self.nesting, + parent_node_execution=self.parent_node_execution.to_flyte_idl() + if self.parent_node_execution is not None + else None, + reference_execution=self.reference_execution.to_flyte_idl() + if self.reference_execution is not None + else None, + system_metadata=self.system_metadata.to_flyte_idl() if self.system_metadata is not None else None, + ) + if self.scheduled_at is not None: + p.scheduled_at.FromDatetime(self.scheduled_at) + return p + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.execution_pb2.ExecutionMetadata pb2_object: + :return: ExecutionMetadata + """ + return cls( + mode=pb2_object.mode, + principal=pb2_object.principal, + nesting=pb2_object.nesting, + scheduled_at=pb2_object.scheduled_at.ToDatetime() if pb2_object.HasField("scheduled_at") else None, + parent_node_execution=_identifier.NodeExecutionIdentifier.from_flyte_idl(pb2_object.parent_node_execution) + if pb2_object.HasField("parent_node_execution") + else None, + reference_execution=_identifier.WorkflowExecutionIdentifier.from_flyte_idl(pb2_object.reference_execution) + if pb2_object.HasField("reference_execution") + else None, + system_metadata=SystemMetadata.from_flyte_idl(pb2_object.system_metadata) + if pb2_object.HasField("system_metadata") + else None, + ) + + +class ExecutionSpec(_common_models.FlyteIdlEntity): + def __init__( + self, + launch_plan, + metadata, + notifications=None, + disable_all=None, + labels=None, + annotations=None, + auth_role=None, + raw_output_data_config=None, + max_parallelism: Optional[int] = None, + security_context: Optional[security.SecurityContext] = None, + overwrite_cache: Optional[bool] = None, + envs: Optional[_common_models.Envs] = None, + tags: Optional[typing.List[str]] = None, + cluster_assignment: Optional[ClusterAssignment] = None, + ): + """ + :param flytekit.models.core.identifier.Identifier launch_plan: Launch plan unique identifier to execute + :param ExecutionMetadata metadata: The metadata to be associated with this execution + :param NotificationList notifications: List of notifications for this execution. + :param bool disable_all: If true, all notifications should be disabled. + :param flytekit.models.common.Labels labels: Labels to apply to the execution. + :param flytekit.models.common.Annotations annotations: Annotations to apply to the execution + :param flytekit.models.common.AuthRole auth_role: The authorization method with which to execute the workflow. + :param raw_output_data_config: Optional location of offloaded data for things like S3, etc. + :param max_parallelism int: Controls the maximum number of tasknodes that can be run in parallel for the entire + workflow. This is useful to achieve fairness. Note: MapTasks are regarded as one unit, and + parallelism/concurrency of MapTasks is independent from this. + :param security_context: Optional security context to use for this execution. + :param overwrite_cache: Optional flag to overwrite the cache for this execution. + :param envs: flytekit.models.common.Envs environment variables to set for this execution. + :param tags: Optional list of tags to apply to the execution. + """ + self._launch_plan = launch_plan + self._metadata = metadata + self._notifications = notifications + self._disable_all = disable_all + self._labels = labels or _common_models.Labels({}) + self._annotations = annotations or _common_models.Annotations({}) + self._auth_role = auth_role or _common_models.AuthRole() + self._raw_output_data_config = raw_output_data_config + self._max_parallelism = max_parallelism + self._security_context = security_context + self._overwrite_cache = overwrite_cache + self._envs = envs + self._tags = tags + self._cluster_assignment = cluster_assignment + + @property + def launch_plan(self): + """ + If the values were too large, this is the URI where the values were offloaded. + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._launch_plan + + @property + def metadata(self): + """ + :rtype: ExecutionMetadata + """ + return self._metadata + + @property + def notifications(self): + """ + :rtype: Optional[NotificationList] + """ + return self._notifications + + @property + def disable_all(self): + """ + :rtype: Optional[bool] + """ + return self._disable_all + + @property + def labels(self): + """ + :rtype: flytekit.models.common.Labels + """ + return self._labels + + @property + def annotations(self): + """ + :rtype: flytekit.models.common.Annotations + """ + return self._annotations + + @property + def auth_role(self): + """ + :rtype: flytekit.models.common.AuthRole + """ + return self._auth_role + + @property + def raw_output_data_config(self): + """ + :rtype: flytekit.models.common.RawOutputDataConfig + """ + return self._raw_output_data_config + + @property + def max_parallelism(self) -> int: + return self._max_parallelism + + @property + def security_context(self) -> typing.Optional[security.SecurityContext]: + return self._security_context + + @property + def overwrite_cache(self) -> Optional[bool]: + return self._overwrite_cache + + @property + def envs(self) -> Optional[_common_models.Envs]: + return self._envs + + @property + def tags(self) -> Optional[typing.List[str]]: + return self._tags + + @property + def cluster_assignment(self) -> Optional[ClusterAssignment]: + return self._cluster_assignment + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.execution_pb2.ExecutionSpec + """ + return _execution_pb2.ExecutionSpec( + launch_plan=self.launch_plan.to_flyte_idl(), + metadata=self.metadata.to_flyte_idl(), + notifications=self.notifications.to_flyte_idl() if self.notifications else None, + disable_all=self.disable_all, # type: ignore + labels=self.labels.to_flyte_idl(), + annotations=self.annotations.to_flyte_idl(), + auth_role=self._auth_role.to_flyte_idl() if self.auth_role else None, + raw_output_data_config=self._raw_output_data_config.to_flyte_idl() + if self._raw_output_data_config + else None, + max_parallelism=self.max_parallelism, + security_context=self.security_context.to_flyte_idl() if self.security_context else None, + overwrite_cache=self.overwrite_cache, + envs=self.envs.to_flyte_idl() if self.envs else None, + tags=self.tags, + cluster_assignment=self._cluster_assignment.to_flyte_idl() if self._cluster_assignment else None, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.admin.execution_pb2.ExecutionSpec p: + :return: ExecutionSpec + """ + return cls( + launch_plan=_identifier.Identifier.from_flyte_idl(p.launch_plan), + metadata=ExecutionMetadata.from_flyte_idl(p.metadata), + notifications=NotificationList.from_flyte_idl(p.notifications) if p.HasField("notifications") else None, + disable_all=p.disable_all if p.HasField("disable_all") else None, + labels=_common_models.Labels.from_flyte_idl(p.labels), + annotations=_common_models.Annotations.from_flyte_idl(p.annotations), + auth_role=_common_models.AuthRole.from_flyte_idl(p.auth_role), + raw_output_data_config=_common_models.RawOutputDataConfig.from_flyte_idl(p.raw_output_data_config) + if p.HasField("raw_output_data_config") + else None, + max_parallelism=p.max_parallelism, + security_context=security.SecurityContext.from_flyte_idl(p.security_context) + if p.security_context + else None, + overwrite_cache=p.overwrite_cache, + envs=_common_models.Envs.from_flyte_idl(p.envs) if p.HasField("envs") else None, + tags=p.tags, + cluster_assignment=ClusterAssignment.from_flyte_idl(p.cluster_assignment) + if p.HasField("cluster_assignment") + else None, + ) + + +class ClusterAssignment(_common_models.FlyteIdlEntity): + def __init__(self, cluster_pool=None): + """ + :param Text cluster_pool: + """ + self._cluster_pool = cluster_pool + + @property + def cluster_pool(self): + """ + :rtype: Text + """ + return self._cluster_pool + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin._cluster_assignment_pb2.ClusterAssignment + """ + return _cluster_assignment_pb2.ClusterAssignment( + cluster_pool_name=self._cluster_pool, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.admin._cluster_assignment_pb2.ClusterAssignment p: + :rtype: flyteidl.admin.ClusterAssignment + """ + return cls(cluster_pool=p.cluster_pool_name) + + +class LiteralMapBlob(_common_models.FlyteIdlEntity): + def __init__(self, values=None, uri=None): + """ + :param flytekit.models.literals.LiteralMap values: + :param Text uri: + """ + self._values = values + self._uri = uri + + @property + def values(self): + """ + :rtype: flytekit.models.literals.LiteralMap + """ + return self._values + + @property + def uri(self): + """ + :rtype: Text + """ + return self._uri + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.execution_pb2.LiteralMapBlob + """ + return _execution_pb2.LiteralMapBlob( + values=self.values.to_flyte_idl() if self.values is not None else None, + uri=self.uri, + ) + + @classmethod + def from_flyte_idl(cls, pb): + """ + :param flyteidl.admin.execution_pb2.LiteralMapBlob pb: + :rtype: LiteralMapBlob + """ + values = None + if pb.HasField("values"): + values = LiteralMapBlob.from_flyte_idl(pb.values) + return cls(values=values, uri=pb.uri if pb.HasField("uri") else None) + + +class Execution(_common_models.FlyteIdlEntity): + def __init__(self, id, spec, closure): + """ + :param flytekit.models.core.identifier.WorkflowExecutionIdentifier id: + :param ExecutionSpec spec: + :param ExecutionClosure closure: + """ + self._id = id + self._spec = spec + self._closure = closure + + @property + def id(self): + """ + :rtype: flytekit.models.core.identifier.WorkflowExecutionIdentifier + """ + return self._id + + @property + def closure(self): + """ + :rtype: ExecutionClosure + """ + return self._closure + + @property + def spec(self): + """ + :rtype: ExecutionSpec + """ + return self._spec + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.execution_pb2.Execution + """ + return _execution_pb2.Execution( + id=self.id.to_flyte_idl(), + closure=self.closure.to_flyte_idl(), + spec=self.spec.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb): + """ + :param flyteidl.admin.execution_pb2.Execution pb: + :rtype: Execution + """ + return cls( + id=_identifier.WorkflowExecutionIdentifier.from_flyte_idl(pb.id), + closure=ExecutionClosure.from_flyte_idl(pb.closure), + spec=ExecutionSpec.from_flyte_idl(pb.spec), + ) + + +class AbortMetadata(_common_models.FlyteIdlEntity): + def __init__(self, cause: str, principal: str): + self._cause = cause + self._principal = principal + + @property + def cause(self) -> str: + return self._cause + + @property + def principal(self) -> str: + return self._principal + + def to_flyte_idl(self) -> flyteidl.admin.execution_pb2.AbortMetadata: + return _execution_pb2.AbortMetadata(cause=self.cause, principal=self.principal) + + @classmethod + def from_flyte_idl(cls, pb2_object: flyteidl.admin.execution_pb2.AbortMetadata) -> AbortMetadata: + return cls( + cause=pb2_object.cause, + principal=pb2_object.principal, + ) + + +class ExecutionClosure(_common_models.FlyteIdlEntity): + def __init__( + self, + phase: int, + started_at: datetime.datetime, + duration: datetime.timedelta, + error: typing.Optional[flytekit.models.core.execution.ExecutionError] = None, + outputs: typing.Optional[LiteralMapBlob] = None, + abort_metadata: typing.Optional[AbortMetadata] = None, + created_at: typing.Optional[datetime.datetime] = None, + updated_at: typing.Optional[datetime.datetime] = None, + ): + """ + :param phase: From the flytekit.models.core.execution.WorkflowExecutionPhase enum + :param started_at: + :param duration: Duration for which the execution has been running. + :param error: + :param outputs: + :param abort_metadata: Specifies metadata around an aborted workflow execution. + """ + self._phase = phase + self._started_at = started_at + self._duration = duration + self._error = error + self._outputs = outputs + self._abort_metadata = abort_metadata + self._created_at = created_at + self._updated_at = updated_at + + @property + def error(self) -> flytekit.models.core.execution.ExecutionError: + return self._error + + @property + def phase(self) -> int: + """ + From the flytekit.models.core.execution.WorkflowExecutionPhase enum + """ + return self._phase + + @property + def started_at(self) -> datetime.datetime: + return self._started_at + + @property + def duration(self) -> datetime.timedelta: + return self._duration + + @property + def created_at(self) -> typing.Optional[datetime.datetime]: + return self._created_at + + @property + def updated_at(self) -> typing.Optional[datetime.datetime]: + return self._updated_at + + @property + def outputs(self) -> LiteralMapBlob: + return self._outputs + + @property + def abort_metadata(self) -> AbortMetadata: + return self._abort_metadata + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.execution_pb2.ExecutionClosure + """ + obj = _execution_pb2.ExecutionClosure( + phase=self.phase, + error=self.error.to_flyte_idl() if self.error is not None else None, + outputs=self.outputs.to_flyte_idl() if self.outputs is not None else None, + abort_metadata=self.abort_metadata.to_flyte_idl() if self.abort_metadata is not None else None, + ) + obj.started_at.FromDatetime(self.started_at.astimezone(_timezone.utc).replace(tzinfo=None)) + obj.duration.FromTimedelta(self.duration) + if self.created_at: + obj.created_at.FromDatetime(self.created_at.astimezone(_timezone.utc).replace(tzinfo=None)) + if self.updated_at: + obj.updated_at.FromDatetime(self.updated_at.astimezone(_timezone.utc).replace(tzinfo=None)) + return obj + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.execution_pb2.ExecutionClosure pb2_object: + :rtype: ExecutionClosure + """ + error = None + if pb2_object.HasField("error"): + error = _core_execution.ExecutionError.from_flyte_idl(pb2_object.error) + outputs = None + if pb2_object.HasField("outputs"): + outputs = LiteralMapBlob.from_flyte_idl(pb2_object.outputs) + abort_metadata = None + if pb2_object.HasField("abort_metadata"): + abort_metadata = AbortMetadata.from_flyte_idl(pb2_object.abort_metadata) + return cls( + error=error, + outputs=outputs, + phase=pb2_object.phase, + started_at=pb2_object.started_at.ToDatetime().replace(tzinfo=_timezone.utc), + duration=pb2_object.duration.ToTimedelta(), + abort_metadata=abort_metadata, + created_at=pb2_object.created_at.ToDatetime().replace(tzinfo=_timezone.utc) + if pb2_object.HasField("created_at") + else None, + updated_at=pb2_object.updated_at.ToDatetime().replace(tzinfo=_timezone.utc) + if pb2_object.HasField("updated_at") + else None, + ) + + +class NotificationList(_common_models.FlyteIdlEntity): + def __init__(self, notifications): + """ + :param list[flytekit.models.common.Notification] notifications: A simple list of notifications. + """ + self._notifications = notifications + + @property + def notifications(self): + """ + :rtype: list[flytekit.models.common.Notification] + """ + return self._notifications + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.execution_pb2.NotificationList + """ + return _execution_pb2.NotificationList(notifications=[n.to_flyte_idl() for n in self.notifications]) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.execution_pb2.NotificationList pb2_object: + :rtype: NotificationList + """ + return cls([_common_models.Notification.from_flyte_idl(p) for p in pb2_object.notifications]) + + +class _CommonDataResponse(_common_models.FlyteIdlEntity): + """ + Currently, node, task, and workflow execution all have the same get data response. So we'll create this common + superclass to reduce code duplication until things diverge in the future. + """ + + def __init__(self, inputs, outputs, full_inputs, full_outputs): + """ + :param _common_models.UrlBlob inputs: + :param _common_models.UrlBlob outputs: + :param _literals_models.LiteralMap full_inputs: + :param _literals_models.LiteralMap full_outputs: + """ + self._inputs = inputs + self._outputs = outputs + self._full_inputs = full_inputs + self._full_outputs = full_outputs + + @property + def inputs(self): + """ + :rtype: _common_models.UrlBlob + """ + return self._inputs + + @property + def outputs(self): + """ + :rtype: _common_models.UrlBlob + """ + return self._outputs + + @property + def full_inputs(self): + """ + :rtype: _literals_models.LiteralMap + """ + return self._full_inputs + + @property + def full_outputs(self): + """ + :rtype: _literals_models.LiteralMap + """ + return self._full_outputs + + +class WorkflowExecutionGetDataResponse(_CommonDataResponse): + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param _execution_pb2.WorkflowExecutionGetDataResponse pb2_object: + :rtype: WorkflowExecutionGetDataResponse + """ + return cls( + inputs=_common_models.UrlBlob.from_flyte_idl(pb2_object.inputs), + outputs=_common_models.UrlBlob.from_flyte_idl(pb2_object.outputs), + full_inputs=_literals_models.LiteralMap.from_flyte_idl(pb2_object.full_inputs), + full_outputs=_literals_models.LiteralMap.from_flyte_idl(pb2_object.full_outputs), + ) + + def to_flyte_idl(self): + """ + :rtype: _execution_pb2.WorkflowExecutionGetDataResponse + """ + return _execution_pb2.WorkflowExecutionGetDataResponse( + inputs=self.inputs.to_flyte_idl(), + outputs=self.outputs.to_flyte_idl(), + full_inputs=self.full_inputs.to_flyte_idl(), + full_outputs=self.full_outputs.to_flyte_idl(), + ) + + +class TaskExecutionGetDataResponse(_CommonDataResponse): + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param _task_execution_pb2.TaskExecutionGetDataResponse pb2_object: + :rtype: TaskExecutionGetDataResponse + """ + return cls( + inputs=_common_models.UrlBlob.from_flyte_idl(pb2_object.inputs), + outputs=_common_models.UrlBlob.from_flyte_idl(pb2_object.outputs), + full_inputs=_literals_models.LiteralMap.from_flyte_idl(pb2_object.full_inputs), + full_outputs=_literals_models.LiteralMap.from_flyte_idl(pb2_object.full_outputs), + ) + + def to_flyte_idl(self): + """ + :rtype: _task_execution_pb2.TaskExecutionGetDataResponse + """ + return _task_execution_pb2.TaskExecutionGetDataResponse( + inputs=self.inputs.to_flyte_idl(), + outputs=self.outputs.to_flyte_idl(), + full_inputs=self.full_inputs.to_flyte_idl(), + full_outputs=self.full_outputs.to_flyte_idl(), + ) + + +class NodeExecutionGetDataResponse(_CommonDataResponse): + def __init__(self, *args, dynamic_workflow: typing.Optional[DynamicWorkflowNodeMetadata] = None, **kwargs): + super().__init__(*args, **kwargs) + self._dynamic_workflow = dynamic_workflow + + @property + def dynamic_workflow(self) -> typing.Optional[DynamicWorkflowNodeMetadata]: + return self._dynamic_workflow + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param _node_execution_pb2.NodeExecutionGetDataResponse pb2_object: + :rtype: NodeExecutionGetDataResponse + """ + return cls( + inputs=_common_models.UrlBlob.from_flyte_idl(pb2_object.inputs), + outputs=_common_models.UrlBlob.from_flyte_idl(pb2_object.outputs), + full_inputs=_literals_models.LiteralMap.from_flyte_idl(pb2_object.full_inputs), + full_outputs=_literals_models.LiteralMap.from_flyte_idl(pb2_object.full_outputs), + dynamic_workflow=DynamicWorkflowNodeMetadata.from_flyte_idl(pb2_object.dynamic_workflow) + if pb2_object.HasField("dynamic_workflow") + else None, + ) + + def to_flyte_idl(self): + """ + :rtype: _node_execution_pb2.NodeExecutionGetDataResponse + """ + return _node_execution_pb2.NodeExecutionGetDataResponse( + inputs=self.inputs.to_flyte_idl(), + outputs=self.outputs.to_flyte_idl(), + full_inputs=self.full_inputs.to_flyte_idl(), + full_outputs=self.full_outputs.to_flyte_idl(), + dynamic_workflow=self.dynamic_workflow.to_flyte_idl() if self.dynamic_workflow else None, + ) diff --git a/flytekit/flytekit/models/filters.py b/flytekit/flytekit/models/filters.py new file mode 100644 index 0000000000..2b0cb04d88 --- /dev/null +++ b/flytekit/flytekit/models/filters.py @@ -0,0 +1,133 @@ +from flytekit.models.common import FlyteIdlEntity as _FlyteIdlEntity + + +class FilterList(_FlyteIdlEntity): + def __init__(self, filter_list): + """ + :param list[Filter] filter_list: List of filters to AND together + """ + self._filter_list = filter_list + + def to_flyte_idl(self): + """ + For supporting the auto-generated REST API, filters must be dumped to a string for representation as GET params. + :rtype: Text + """ + return "+".join([f.to_flyte_idl() for f in self._filter_list]) + + @classmethod + def from_flyte_idl(cls): + raise NotImplementedError("Filters are never recovered from a protobuf.") + + +class Filter(_FlyteIdlEntity): + _comparator = "nil" + + def __init__(self, key, value): + """ + :param Text key: The name of the field to compare against + :param Text value: The textual value of the field to compare against + """ + self._key = key + self._value = value + + def to_flyte_idl(self): + """ + For supporting the auto-generated REST API, filters must be dumped to a string for representation as GET params. + :rtype: Text + """ + return "{}({},{})".format(type(self)._comparator, self._key, self._value) + + @classmethod + def from_flyte_idl(cls): + raise NotImplementedError("Filters are never recovered from a protobuf.") + + @classmethod + def from_python_std(cls, string): + """ + :param Text string: + :rtype: Filter + """ + if string.startswith("eq("): + return Equal._parse_from_string(string) + elif string.startswith("ne("): + return NotEqual._parse_from_string(string) + elif string.startswith("gt("): + return GreaterThan._parse_from_string(string) + elif string.startswith("gte("): + return GreaterThanOrEqual._parse_from_string(string) + elif string.startswith("lt("): + return LessThan._parse_from_string(string) + elif string.startswith("lte("): + return LessThanOrEqual._parse_from_string(string) + elif string.startswith("contains("): + return Contains._parse_from_string(string) + elif string.startswith("value_in("): + return ValueIn._parse_from_string(string) + else: + raise ValueError("'{}' could not be parsed into a filter.".format(string)) + + @classmethod + def _parse_from_string(cls, string): + """ + :param Text string: + :rtype: Filter + """ + stripped = string[len(cls._comparator) + 1 :] + if stripped[-1] != ")": + raise ValueError("Filter could not be parsed because {} did not end with a ')'".format(string)) + split = stripped[:-1].split(",") + if len(split) != 2: + raise ValueError("Filter must be expressed as a key, value tuple like 'eq(abc,def)'") + key = split[0].strip() + value = split[1].strip() + return cls(key, cls._parse_value(value)) + + @classmethod + def _parse_value(cls, value): + return value + + +class Equal(Filter): + _comparator = "eq" + + +class NotEqual(Filter): + _comparator = "ne" + + +class GreaterThan(Filter): + _comparator = "gt" + + +class GreaterThanOrEqual(Filter): + _comparator = "gte" + + +class LessThan(Filter): + _comparator = "lt" + + +class LessThanOrEqual(Filter): + _comparator = "lte" + + +class SetFilter(Filter): + def __init__(self, key, values): + """ + :param Text key: The name of the field to compare against + :param list[Text] values: A list of textual values to compare. + """ + super(SetFilter, self).__init__(key, ";".join(values)) + + @classmethod + def _parse_value(cls, value): + return value.split(";") + + +class Contains(SetFilter): + _comparator = "contains" + + +class ValueIn(SetFilter): + _comparator = "value_in" diff --git a/flytekit/flytekit/models/interface.py b/flytekit/flytekit/models/interface.py new file mode 100644 index 0000000000..f80bfb9e52 --- /dev/null +++ b/flytekit/flytekit/models/interface.py @@ -0,0 +1,270 @@ +import typing + +from flyteidl.core import artifact_id_pb2 as art_id +from flyteidl.core import interface_pb2 as _interface_pb2 + +from flytekit.models import common as _common +from flytekit.models import literals as _literals +from flytekit.models import types as _types + + +class Variable(_common.FlyteIdlEntity): + def __init__( + self, + type, + description, + artifact_partial_id: typing.Optional[art_id.ArtifactID] = None, + artifact_tag: typing.Optional[art_id.ArtifactTag] = None, + ): + """ + :param flytekit.models.types.LiteralType type: This describes the type of value that must be provided to + satisfy this variable. + :param Text description: This is a help string that can provide context for what this variable means in relation + to a task or workflow. + :param artifact_partial_id: Optional Artifact object to control how the artifact is created when the task runs. + :param artifact_tag: Optional ArtifactTag object to automatically tag things. + """ + self._type = type + self._description = description + self._artifact_partial_id = artifact_partial_id + self._artifact_tag = artifact_tag + + @property + def type(self): + """ + This describes the type of value that must be provided to satisfy this variable. + :rtype: flytekit.models.types.LiteralType + """ + return self._type + + @property + def description(self): + """ + This is a help string that can provide context for what this variable means in relation to a task or workflow. + :rtype: Text + """ + return self._description + + @property + def artifact_partial_id(self) -> typing.Optional[art_id.ArtifactID]: + return self._artifact_partial_id + + @property + def artifact_tag(self) -> typing.Optional[art_id.ArtifactTag]: + return self._artifact_tag + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.interface_pb2.Variable + """ + return _interface_pb2.Variable( + type=self.type.to_flyte_idl(), + description=self.description, + artifact_partial_id=self.artifact_partial_id, + artifact_tag=self.artifact_tag, + ) + + @classmethod + def from_flyte_idl(cls, variable_proto) -> _interface_pb2.Variable: + """ + :param flyteidl.core.interface_pb2.Variable variable_proto: + """ + return cls( + type=_types.LiteralType.from_flyte_idl(variable_proto.type), + description=variable_proto.description, + artifact_partial_id=variable_proto.artifact_partial_id + if variable_proto.HasField("artifact_partial_id") + else None, + artifact_tag=variable_proto.artifact_tag if variable_proto.HasField("artifact_tag") else None, + ) + + +class VariableMap(_common.FlyteIdlEntity): + def __init__(self, variables): + """ + A map of Variables + + :param dict[Text, Variable] variables: + """ + self._variables = variables + + @property + def variables(self): + """ + :rtype: dict[Text, Variable] + """ + return self._variables + + def to_flyte_idl(self): + """ + :rtype: dict[Text, Variable] + """ + return _interface_pb2.VariableMap(variables={k: v.to_flyte_idl() for k, v in self.variables.items()}) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param dict[Text, Variable] pb2_object: + :rtype: VariableMap + """ + return cls({k: Variable.from_flyte_idl(v) for k, v in pb2_object.variables.items()}) + + +class TypedInterface(_common.FlyteIdlEntity): + def __init__(self, inputs, outputs): + """ + Please note that this model is slightly incorrect, but is more user-friendly. The underlying inputs and + outputs are represented directly as Python dicts, rather than going through the additional VariableMap layer. + + :param dict[Text, Variable] inputs: This defines the names and types for the interface's inputs. + :param dict[Text, Variable] outputs: This defines the names and types for the interface's outputs. + """ + self._inputs = inputs + self._outputs = outputs + + @property + def inputs(self) -> typing.Dict[str, Variable]: + return self._inputs + + @property + def outputs(self) -> typing.Dict[str, Variable]: + return self._outputs + + def to_flyte_idl(self) -> _interface_pb2.TypedInterface: + return _interface_pb2.TypedInterface( + inputs=_interface_pb2.VariableMap(variables={k: v.to_flyte_idl() for k, v in self.inputs.items()}), + outputs=_interface_pb2.VariableMap(variables={k: v.to_flyte_idl() for k, v in self.outputs.items()}), + ) + + @classmethod + def from_flyte_idl(cls, proto: _interface_pb2.TypedInterface) -> "TypedInterface": + """ + :param proto: + """ + return cls( + inputs={k: Variable.from_flyte_idl(v) for k, v in proto.inputs.variables.items()}, + outputs={k: Variable.from_flyte_idl(v) for k, v in proto.outputs.variables.items()}, + ) + + +class Parameter(_common.FlyteIdlEntity): + def __init__( + self, + var, + default=None, + required=None, + artifact_query: typing.Optional[art_id.ArtifactQuery] = None, + artifact_id: typing.Optional[art_id.ArtifactID] = None, + ): + """ + Declares an input parameter. A parameter is used as input to a launch plan and has + the special ability to have a default value or mark itself as required. + :param Variable var: Defines a name and a type to reference/compare through out the system. + :param flytekit.models.literals.Literal default: [Optional] Defines a default value that has to match the + variable type defined. + :param bool required: [Optional] is this value required to be filled in? + :param artifact_query: Specify this to bind to a query instead of a constant. + :param artifact_id: When you want to bind to a known artifact pointer. + """ + self._var = var + self._default = default + self._required = required + self._artifact_query = artifact_query + self._artifact_id = artifact_id + + @property + def var(self): + """ + The variable definition for this input parameter. + :rtype: Variable + """ + return self._var + + @property + def default(self): + """ + This is the default literal value that will be applied for this parameter if not user specified. + :rtype: flytekit.models.literals.Literal + """ + return self._default + + @property + def required(self) -> bool: + """ + If True, this parameter must be specified. There cannot be a default value. + :rtype: bool + """ + return self._required + + @property + def behavior(self): + """ + :rtype: T + """ + return self._default or self._required or self._artifact_query + + @property + def artifact_query(self) -> typing.Optional[art_id.ArtifactQuery]: + return self._artifact_query + + @property + def artifact_id(self) -> typing.Optional[art_id.ArtifactID]: + return self._artifact_id + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.interface_pb2.Parameter + """ + return _interface_pb2.Parameter( + var=self.var.to_flyte_idl(), + default=self.default.to_flyte_idl() if self.default is not None else None, + required=self.required if self.default is None and self.artifact_query is None else None, + artifact_query=self.artifact_query if self.artifact_query else None, + artifact_id=self.artifact_id if self.artifact_id else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.interface_pb2.Parameter pb2_object: + :rtype: Parameter + """ + return cls( + Variable.from_flyte_idl(pb2_object.var), + _literals.Literal.from_flyte_idl(pb2_object.default) if pb2_object.HasField("default") else None, + pb2_object.required if pb2_object.HasField("required") else None, + artifact_query=pb2_object.artifact_query if pb2_object.HasField("artifact_query") else None, + artifact_id=pb2_object.artifact_id if pb2_object.HasField("artifact_id") else None, + ) + + +class ParameterMap(_common.FlyteIdlEntity): + def __init__(self, parameters): + """ + A map of Parameters + :param dict[Text, Parameter]: parameters + """ + self._parameters = parameters + + @property + def parameters(self): + """ + :rtype: dict[Text, Parameter] + """ + return self._parameters + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.interface_pb2.ParameterMap + """ + return _interface_pb2.ParameterMap( + parameters={k: v.to_flyte_idl() for k, v in self.parameters.items()}, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.interface_pb2.ParameterMap pb2_object: + :rtype: ParameterMap + """ + return cls(parameters={k: Parameter.from_flyte_idl(v) for k, v in pb2_object.parameters.items()}) diff --git a/flytekit/flytekit/models/launch_plan.py b/flytekit/flytekit/models/launch_plan.py new file mode 100644 index 0000000000..9f2af1b92e --- /dev/null +++ b/flytekit/flytekit/models/launch_plan.py @@ -0,0 +1,424 @@ +import typing + +from flyteidl.admin import launch_plan_pb2 as _launch_plan +from google.protobuf.any_pb2 import Any + +from flytekit.models import common as _common +from flytekit.models import interface as _interface +from flytekit.models import literals as _literals +from flytekit.models import schedule as _schedule +from flytekit.models import security +from flytekit.models.core import identifier as _identifier + + +class LaunchPlanMetadata(_common.FlyteIdlEntity): + def __init__(self, schedule, notifications, launch_conditions=None): + """ + + :param flytekit.models.schedule.Schedule schedule: Schedule to execute the Launch Plan + :param list[flytekit.models.common.Notification] notifications: List of notifications based on + execution status transitions + :param launch_conditions: Additional metadata for launching + """ + self._schedule = schedule + self._notifications = notifications + self._launch_conditions = launch_conditions + + @property + def schedule(self): + """ + Schedule to execute the Launch Plan + :rtype: flytekit.models.schedule.Schedule + """ + return self._schedule + + @property + def notifications(self): + """ + List of notifications based on Execution status transitions + :rtype: list[flytekit.models.common.Notification] + """ + return self._notifications + + @property + def launch_conditions(self): + return self._launch_conditions + + def to_flyte_idl(self): + """ + List of notifications based on Execution status transitions + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanMetadata + """ + if self.launch_conditions: + a = Any() + a.Pack(self.launch_conditions) + else: + a = None + return _launch_plan.LaunchPlanMetadata( + schedule=self.schedule.to_flyte_idl() if self.schedule is not None else None, + notifications=[n.to_flyte_idl() for n in self.notifications], + launch_conditions=a, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.launch_plan_pb2.LaunchPlanMetadata pb2_object: + :rtype: LaunchPlanMetadata + """ + return cls( + schedule=_schedule.Schedule.from_flyte_idl(pb2_object.schedule) + if pb2_object.HasField("schedule") + else None, + notifications=[_common.Notification.from_flyte_idl(n) for n in pb2_object.notifications], + launch_conditions=pb2_object.launch_conditions if pb2_object.HasField("launch_conditions") else None, + ) + + +class Auth(_common.FlyteIdlEntity): + def __init__(self, assumable_iam_role=None, kubernetes_service_account=None): + """ + DEPRECATED. Do not use. Use flytekit.models.common.AuthRole instead + At most one of assumable_iam_role or kubernetes_service_account can be set. + :param Text assumable_iam_role: IAM identity with set permissions policies. + :param Text kubernetes_service_account: Provides an identity for workflow execution resources. Flyte deployment + administrators are responsible for handling permissions as they relate to the service account. + """ + self._assumable_iam_role = assumable_iam_role + self._kubernetes_service_account = kubernetes_service_account + + @property + def assumable_iam_role(self): + """ + The IAM role to execute the workflow with + :rtype: Text + """ + return self._assumable_iam_role + + @property + def kubernetes_service_account(self): + """ + The kubernetes service account to execute the workflow with + :rtype: Text + """ + return self._kubernetes_service_account + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.launch_plan_pb2.Auth + """ + return _launch_plan.Auth( + assumable_iam_role=self.assumable_iam_role if self.assumable_iam_role else None, + kubernetes_service_account=self.kubernetes_service_account if self.kubernetes_service_account else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.launch_plan_pb2.Auth pb2_object: + :rtype: Auth + """ + return cls( + assumable_iam_role=pb2_object.assumable_iam_role, + kubernetes_service_account=pb2_object.kubernetes_service_account, + ) + + +class LaunchPlanSpec(_common.FlyteIdlEntity): + def __init__( + self, + workflow_id, + entity_metadata, + default_inputs, + fixed_inputs, + labels: _common.Labels, + annotations: _common.Annotations, + auth_role: _common.AuthRole, + raw_output_data_config: _common.RawOutputDataConfig, + max_parallelism: typing.Optional[int] = None, + security_context: typing.Optional[security.SecurityContext] = None, + ): + """ + The spec for a Launch Plan. + + :param flytekit.models.core.identifier.Identifier workflow_id: Unique identifier for the workflow in question + :param LaunchPlanMetadata entity_metadata: Metadata + :param flytekit.models.interface.ParameterMap default_inputs: Input values to be passed for the execution + :param flytekit.models.literals.LiteralMap fixed_inputs: Fixed, non-overridable inputs for the Launch Plan + :param flytekit.models.common.Labels: + Any custom kubernetes labels to apply to workflows executed by this launch plan. + :param flytekit.models.common.Annotations annotations: + Any custom kubernetes annotations to apply to workflows executed by this launch plan. + :param flytekit.models.common.AuthRole auth_role: The auth method with which to execute the workflow. + :param flytekit.models.common.RawOutputDataConfig raw_output_data_config: Value for where to store offloaded + data like Blobs and Schemas. + :param max_parallelism int: Controls the maximum number of tasknodes that can be run in parallel for the entire + workflow. This is useful to achieve fairness. Note: MapTasks are regarded as one unit, and + parallelism/concurrency of MapTasks is independent from this. + :param security_context: This can be used to add security information to a LaunchPlan, which will be used by + every execution + """ + self._workflow_id = workflow_id + self._entity_metadata = entity_metadata + self._default_inputs = default_inputs + self._fixed_inputs = fixed_inputs + self._labels = labels + self._annotations = annotations + self._auth_role = auth_role + self._raw_output_data_config = raw_output_data_config + self._max_parallelism = max_parallelism + self._security_context = security_context + + @property + def workflow_id(self): + """ + Unique identifier for the workflow in question + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._workflow_id + + @property + def entity_metadata(self): + """ + :rtype: LaunchPlanMetadata + """ + return self._entity_metadata + + @property + def default_inputs(self): + """ + Input values to be passed for the execution + :rtype: flytekit.models.interface.ParameterMap + """ + return self._default_inputs + + @property + def fixed_inputs(self): + """ + Fixed, non-overridable inputs for the Launch Plan + :rtype: flytekit.models.literals.LiteralMap + """ + return self._fixed_inputs + + @property + def labels(self) -> _common.Labels: + """ + The labels to execute the workflow with + :rtype: flytekit.models.common.Labels + """ + return self._labels + + @property + def annotations(self) -> _common.Annotations: + """ + The annotations to execute the workflow with + :rtype: flytekit.models.common.Annotations + """ + return self._annotations + + @property + def auth_role(self): + """ + The authorization method with which to execute the workflow. + :rtype: flytekit.models.common.AuthRole + """ + return self._auth_role + + @property + def raw_output_data_config(self): + """ + Where to store offloaded data like Blobs and Schemas + :rtype: flytekit.models.common.RawOutputDataConfig + """ + return self._raw_output_data_config + + @property + def max_parallelism(self) -> typing.Optional[int]: + return self._max_parallelism + + @property + def security_context(self) -> typing.Optional[security.SecurityContext]: + return self._security_context + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanSpec + """ + return _launch_plan.LaunchPlanSpec( + workflow_id=self.workflow_id.to_flyte_idl(), + entity_metadata=self.entity_metadata.to_flyte_idl(), + default_inputs=self.default_inputs.to_flyte_idl(), + fixed_inputs=self.fixed_inputs.to_flyte_idl(), + labels=self.labels.to_flyte_idl(), + annotations=self.annotations.to_flyte_idl(), + auth_role=self.auth_role.to_flyte_idl() if self.auth_role else None, + raw_output_data_config=self.raw_output_data_config.to_flyte_idl(), + max_parallelism=self.max_parallelism, + security_context=self.security_context.to_flyte_idl() if self.security_context else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2): + """ + :param flyteidl.admin.launch_plan_pb2.LaunchPlanSpec pb2: + :rtype: LaunchPlanSpec + """ + auth_role = None + # First check the newer field, auth_role. + if pb2.auth_role is not None and (pb2.auth_role.assumable_iam_role or pb2.auth_role.kubernetes_service_account): + auth_role = _common.AuthRole.from_flyte_idl(pb2.auth_role) + # Fallback to the deprecated field. + elif pb2.auth is not None: + if pb2.auth.assumable_iam_role: + auth_role = _common.AuthRole(assumable_iam_role=pb2.auth.assumable_iam_role) + else: + auth_role = _common.AuthRole(assumable_iam_role=pb2.auth.kubernetes_service_account) + + return cls( + workflow_id=_identifier.Identifier.from_flyte_idl(pb2.workflow_id), + entity_metadata=LaunchPlanMetadata.from_flyte_idl(pb2.entity_metadata), + default_inputs=_interface.ParameterMap.from_flyte_idl(pb2.default_inputs), + fixed_inputs=_literals.LiteralMap.from_flyte_idl(pb2.fixed_inputs), + labels=_common.Labels.from_flyte_idl(pb2.labels), + annotations=_common.Annotations.from_flyte_idl(pb2.annotations), + auth_role=auth_role, + raw_output_data_config=_common.RawOutputDataConfig.from_flyte_idl(pb2.raw_output_data_config), + max_parallelism=pb2.max_parallelism, + security_context=security.SecurityContext.from_flyte_idl(pb2.security_context) + if pb2.security_context + else None, + ) + + +class LaunchPlanState(object): + INACTIVE = _launch_plan.INACTIVE + ACTIVE = _launch_plan.ACTIVE + + @classmethod + def enum_to_string(cls, val): + """ + :param int val: + :rtype: Text + """ + if val == cls.INACTIVE: + return "INACTIVE" + elif val == cls.ACTIVE: + return "ACTIVE" + else: + return "" + + +class LaunchPlanClosure(_common.FlyteIdlEntity): + def __init__(self, state, expected_inputs, expected_outputs): + """ + :param LaunchPlanState state: Indicate the Launch plan phase + :param flytekit.models.interface.ParameterMap expected_inputs: Indicates the set of inputs to execute + the Launch plan + :param flytekit.models.interface.VariableMap expected_outputs: Indicates the set of outputs from the Launch plan + """ + self._state = state + self._expected_inputs = expected_inputs + self._expected_outputs = expected_outputs + + @property + def state(self): + """ + :rtype: LaunchPlanState + """ + return self._state + + @property + def expected_inputs(self): + """ + :rtype: flytekit.models.interface.ParameterMap + """ + return self._expected_inputs + + @property + def expected_outputs(self): + """ + :rtype: flytekit.models.interface.VariableMap + """ + return self._expected_outputs + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlanClosure + """ + return _launch_plan.LaunchPlanClosure( + state=self.state, + expected_inputs=self.expected_inputs.to_flyte_idl(), + expected_outputs=self.expected_outputs.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.launch_plan_pb2.LaunchPlanClosure pb2_object: + :rtype: LaunchPlanClosure + """ + return cls( + pb2_object.state, + _interface.ParameterMap.from_flyte_idl(pb2_object.expected_inputs), + _interface.VariableMap.from_flyte_idl(pb2_object.expected_outputs), + ) + + +class LaunchPlan(_common.FlyteIdlEntity): + def __init__(self, id, spec, closure): + """ + :param flytekit.models.core.identifier.Identifier id: + :param LaunchPlanSpec spec: + :param LaunchPlanClosure closure: + """ + self._id = id + self._spec = spec + self._closure = closure + + @property + def id(self): + """ + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._id + + @property + def spec(self): + """ + :rtype: LaunchPlanSpec + """ + return self._spec + + @property + def closure(self): + """ + :rtype: LaunchPlanClosure + """ + return self._closure + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.launch_plan_pb2.LaunchPlan + """ + identifier = ( + self.id + if self.id is not None + else _identifier.Identifier(_identifier.ResourceType.LAUNCH_PLAN, None, None, None, None) + ) + return _launch_plan.LaunchPlan( + id=identifier.to_flyte_idl(), + spec=self.spec.to_flyte_idl(), + closure=self.closure.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.launch_plan_pb2.LaunchPlan pb2_object: + :rtype: LaunchPlan + """ + return cls( + id=_identifier.Identifier.from_flyte_idl(pb2_object.id), + spec=LaunchPlanSpec.from_flyte_idl(pb2_object.spec), + closure=LaunchPlanClosure.from_flyte_idl(pb2_object.closure), + ) diff --git a/flytekit/flytekit/models/literals.py b/flytekit/flytekit/models/literals.py new file mode 100644 index 0000000000..c59de4afde --- /dev/null +++ b/flytekit/flytekit/models/literals.py @@ -0,0 +1,956 @@ +from datetime import datetime as _datetime +from datetime import timezone as _timezone +from typing import Dict, Optional + +from flyteidl.core import literals_pb2 as _literals_pb2 +from google.protobuf.struct_pb2 import Struct + +from flytekit.exceptions import user as _user_exceptions +from flytekit.models import common as _common +from flytekit.models.core import types as _core_types +from flytekit.models.types import Error, StructuredDatasetType +from flytekit.models.types import LiteralType as _LiteralType +from flytekit.models.types import OutputReference as _OutputReference +from flytekit.models.types import SchemaType as _SchemaType + + +class RetryStrategy(_common.FlyteIdlEntity): + def __init__(self, retries): + """ + :param int retries: Number of retries to attempt on recoverable failures. If retries is 0, then + only one attempt will be made. + """ + self._retries = retries + + @property + def retries(self): + """ + Number of retries to attempt on recoverable failures. If retries is 0, then only one attempt will be made. + :rtype: int + """ + return self._retries + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.RetryStrategy + """ + return _literals_pb2.RetryStrategy(retries=self.retries) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.RetryStrategy pb2_object: + :rtype: RetryStrategy + """ + return cls(retries=pb2_object.retries) + + +class Primitive(_common.FlyteIdlEntity): + def __init__( + self, + integer=None, + float_value=None, + string_value=None, + boolean=None, + datetime=None, + duration=None, + ): + """ + This object proxies the primitives supported by the Flyte IDL system. Only one value can be set. + :param int integer: [Optional] + :param float float_value: [Optional] + :param Text string_value: [Optional] + :param bool boolean: [Optional] + :param datetime.timestamp datetime: [Optional] + :param datetime.timedelta duration: [Optional] + """ + self._integer = integer + self._float_value = float_value + self._string_value = string_value + self._boolean = boolean + if datetime is None: + self._datetime = None + elif isinstance(datetime, _datetime): + self._datetime = datetime + else: # TODO Check for timestamp type? + self._datetime = _datetime.utcfromtimestamp(datetime.seconds) + self._duration = duration + + @property + def integer(self): + """ + :rtype: int + """ + return self._integer + + @property + def float_value(self): + """ + :rtype: float + """ + return self._float_value + + @property + def string_value(self): + """ + :rtype: Text + """ + return self._string_value + + @property + def boolean(self): + """ + :rtype: bool + """ + return self._boolean + + @property + def datetime(self): + """ + :rtype: datetime.datetime + """ + if self._datetime is None or self._datetime.tzinfo is not None: + return self._datetime + return self._datetime.replace(tzinfo=_timezone.utc) + + @property + def duration(self): + """ + :rtype: datetime.timedelta + """ + return self._duration + + @property + def value(self): + """ + This returns whichever field is set. + :rtype: T + """ + for value in [ + self.integer, + self.float_value, + self.string_value, + self.boolean, + self.datetime, + self.duration, + ]: + if value is not None: + return value + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Primitive + """ + primitive = _literals_pb2.Primitive( + integer=self.integer, + float_value=self.float_value, + string_value=self.string_value, + boolean=self.boolean, + ) + if self.datetime is not None: + # Convert to UTC and remove timezone so protobuf behaves. + primitive.datetime.FromDatetime(self.datetime.astimezone(_timezone.utc).replace(tzinfo=None)) + if self.duration is not None: + primitive.duration.FromTimedelta(self.duration) + return primitive + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.literals_pb2.Primitive proto: + :rtype: Primitive + """ + return cls( + integer=proto.integer if proto.HasField("integer") else None, + float_value=proto.float_value if proto.HasField("float_value") else None, + string_value=proto.string_value if proto.HasField("string_value") else None, + boolean=proto.boolean if proto.HasField("boolean") else None, + datetime=proto.datetime.ToDatetime().replace(tzinfo=_timezone.utc) if proto.HasField("datetime") else None, + duration=proto.duration.ToTimedelta() if proto.HasField("duration") else None, + ) + + +class Binary(_common.FlyteIdlEntity): + def __init__(self, value, tag): + """ + :param bytes value: + :param Text tag: + """ + self._value = value + self._tag = tag + + @property + def value(self): + """ + :rtype: bytes + """ + return self._value + + @property + def tag(self): + """ + :rtype: Text + """ + return self._tag + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Binary + """ + return _literals_pb2.Binary(value=self.value, tag=self.tag) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.Binary pb2_object: + :rtype: Binary + """ + return cls(value=pb2_object.value, tag=pb2_object.tag) + + +class BlobMetadata(_common.FlyteIdlEntity): + """ + This is metadata for the Blob literal. + """ + + def __init__(self, type): + """ + :param flytekit.models.core.types.BlobType type: The type of the underlying blob + """ + self._type = type + + @property + def type(self): + """ + :rtype: flytekit.models.core.types.BlobType + """ + return self._type + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.BlobMetadata + """ + return _literals_pb2.BlobMetadata(type=self.type.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.literals_pb2.BlobMetadata proto: + :rtype: BlobMetadata + """ + return cls(type=_core_types.BlobType.from_flyte_idl(proto.type)) + + +class Blob(_common.FlyteIdlEntity): + def __init__(self, metadata, uri): + """ + This literal model is used to represent binary data offloaded to some storage location which is + identifiable with a unique string. See :py:class:`flytekit.FlyteFile` as an example. + + :param BlobMetadata metadata: + :param Text uri: The location of this blob + """ + self._metadata = metadata + self._uri = uri + + @property + def uri(self): + """ + :rtype: Text + """ + return self._uri + + @property + def metadata(self): + """ + :rtype: BlobMetadata + """ + return self._metadata + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Blob + """ + return _literals_pb2.Blob(metadata=self.metadata.to_flyte_idl(), uri=self.uri) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.literals_pb2.Blob proto: + :rtype: Blob + """ + return cls(metadata=BlobMetadata.from_flyte_idl(proto.metadata), uri=proto.uri) + + +class Void(_common.FlyteIdlEntity): + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Void + """ + return _literals_pb2.Void() + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.literals_pb2.Void proto: + :rtype: Void + """ + return cls() + + +class BindingDataMap(_common.FlyteIdlEntity): + def __init__(self, bindings): + """ + A map of BindingData items. Can be a recursive structure + + :param dict[string, BindingData] bindings: Map of strings to Bindings + """ + self._bindings = bindings + + @property + def bindings(self): + """ + Map of strings to Bindings + :rtype: dict[string, BindingData] + """ + return self._bindings + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.BindingDataMap + """ + return _literals_pb2.BindingDataMap(bindings={k: v.to_flyte_idl() for (k, v) in self.bindings.items()}) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.BindingDataMap pb2_object: + :rtype: flytekit.models.literals.BindingDataMap + """ + + return cls({k: BindingData.from_flyte_idl(v) for (k, v) in pb2_object.bindings.items()}) + + +class BindingDataCollection(_common.FlyteIdlEntity): + def __init__(self, bindings): + """ + A list of BindingData items. + + :param list[BindingData] bindings: + """ + self._bindings = bindings + + @property + def bindings(self): + """ + :rtype: list[BindingData] + """ + return self._bindings + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.BindingDataCollection + """ + return _literals_pb2.BindingDataCollection(bindings=[b.to_flyte_idl() for b in self.bindings]) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.BindingDataCollection pb2_object: + :rtype: flytekit.models.literals.BindingDataCollection + """ + return cls([BindingData.from_flyte_idl(b) for b in pb2_object.bindings]) + + +class BindingData(_common.FlyteIdlEntity): + def __init__(self, scalar=None, collection=None, promise=None, map=None): + """ + Specifies either a simple value or a reference to another output. Only one of the input arguments may be + specified. + + :param Scalar scalar: [Optional] A simple scalar value. + :param BindingDataCollection collection: [Optional] A collection of binding data. This allows nesting of + binding data to any number of levels. + :param flytekit.models.types.OutputReference promise: [Optional] References an output promised by another node. + :param BindingDataMap map: [Optional] A map of bindings. The key is always a string. + """ + self._scalar = scalar + self._collection = collection + self._promise = promise + self._map = map + + @property + def scalar(self): + """ + A simple scalar value. + :rtype: Scalar + """ + return self._scalar + + @property + def collection(self): + """ + [Optional] A collection of binding data. This allows nesting of binding data to any number of levels. + :rtype: BindingDataCollection + """ + return self._collection + + @property + def promise(self): + """ + [Optional] References an output promised by another node. + :rtype: flytekit.models.types.OutputReference + """ + return self._promise + + @property + def map(self): + """ + [Optional] A map of bindings. The key is always a string. + :rtype: BindingDataMap + """ + return self._map + + @property + def value(self): + """ + Returns whichever value is set + :rtype: T + """ + return self.scalar or self.collection or self.promise or self.map + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.BindingData + """ + return _literals_pb2.BindingData( + scalar=self.scalar.to_flyte_idl() if self.scalar is not None else None, + collection=self.collection.to_flyte_idl() if self.collection is not None else None, + promise=self.promise.to_flyte_idl() if self.promise is not None else None, + map=self.map.to_flyte_idl() if self.map is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.BindingData pb2_object: + :return: BindingData + """ + return cls( + scalar=Scalar.from_flyte_idl(pb2_object.scalar) if pb2_object.HasField("scalar") else None, + collection=BindingDataCollection.from_flyte_idl(pb2_object.collection) + if pb2_object.HasField("collection") + else None, + promise=_OutputReference.from_flyte_idl(pb2_object.promise) if pb2_object.HasField("promise") else None, + map=BindingDataMap.from_flyte_idl(pb2_object.map) if pb2_object.HasField("map") else None, + ) + + def to_literal_model(self): + """ + Converts current binding data into a Literal asserting that there are no promises in the bindings. + :rtype: Literal + """ + if self.promise: + raise _user_exceptions.FlyteValueException( + self.promise, + "Cannot convert BindingData to a Literal because " "it has a promise.", + ) + elif self.scalar: + return Literal(scalar=self.scalar) + elif self.collection: + return Literal( + collection=LiteralCollection( + literals=[binding.to_literal_model() for binding in self.collection.bindings] + ) + ) + elif self.map: + return Literal( + map=LiteralMap(literals={k: binding.to_literal_model() for k, binding in self.map.bindings.items()}) + ) + + +class Binding(_common.FlyteIdlEntity): + def __init__(self, var, binding): + """ + An input/output binding of a variable to either static value or a node output. + + :param Text var: A variable name, must match an input or output variable of the node. + :param BindingData binding: Data to use to bind this variable. + """ + self._var = var + self._binding = binding + + @property + def var(self): + """ + A variable name, must match an input or output variable of the node. + :rtype: Text + """ + return self._var + + @property + def binding(self): + """ + Data to use to bind this variable. + :rtype: BindingData + """ + return self._binding + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Binding + """ + return _literals_pb2.Binding(var=self.var, binding=self.binding.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.Binding pb2_object: + :rtype: flytekit.core.models.literals.Binding + """ + return cls(pb2_object.var, BindingData.from_flyte_idl(pb2_object.binding)) + + +class Schema(_common.FlyteIdlEntity): + def __init__(self, uri, type): + """ + A strongly typed schema that defines the interface of data retrieved from the underlying storage medium. + + :param Text uri: + :param flytekit.models.types.SchemaType type: + """ + self._uri = uri + self._type = type + + @property + def uri(self): + """ + :rtype: Text + """ + return self._uri + + @property + def type(self): + """ + :rtype: flytekit.models.types.SchemaType + """ + return self._type + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Schema + """ + return _literals_pb2.Schema(uri=self.uri, type=self.type.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.Schema pb2_object: + :rtype: Schema + """ + return cls(uri=pb2_object.uri, type=_SchemaType.from_flyte_idl(pb2_object.type)) + + +class Union(_common.FlyteIdlEntity): + def __init__(self, value, stored_type): + """ + The runtime representation of a tagged union value. See `UnionType` for more details. + + :param flytekit.models.literals.Literal value: + :param flytekit.models.types.LiteralType stored_type: + """ + self._value = value + self._type = stored_type + + @property + def value(self): + """ + :rtype: flytekit.models.literals.Literal + """ + return self._value + + @property + def stored_type(self): + """ + :rtype: flytekit.models.types.LiteralType + """ + return self._type + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Union + """ + return _literals_pb2.Union(value=self.value.to_flyte_idl(), type=self._type.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.Schema pb2_object: + :rtype: Schema + """ + return cls( + value=Literal.from_flyte_idl(pb2_object.value), stored_type=_LiteralType.from_flyte_idl(pb2_object.type) + ) + + +class StructuredDatasetMetadata(_common.FlyteIdlEntity): + def __init__(self, structured_dataset_type: Optional[StructuredDatasetType] = None): + self._structured_dataset_type = structured_dataset_type + + @property + def structured_dataset_type(self) -> StructuredDatasetType: + return self._structured_dataset_type + + def to_flyte_idl(self) -> _literals_pb2.StructuredDatasetMetadata: + return _literals_pb2.StructuredDatasetMetadata( + structured_dataset_type=self.structured_dataset_type.to_flyte_idl() + if self._structured_dataset_type + else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _literals_pb2.StructuredDatasetMetadata) -> "StructuredDatasetMetadata": + return cls( + structured_dataset_type=StructuredDatasetType.from_flyte_idl(pb2_object.structured_dataset_type), + ) + + +class StructuredDataset(_common.FlyteIdlEntity): + def __init__(self, uri: str, metadata: Optional[StructuredDatasetMetadata] = None): + """ + A strongly typed schema that defines the interface of data retrieved from the underlying storage medium. + """ + self._uri = uri + self._metadata = metadata + + @property + def uri(self) -> str: + return self._uri + + @property + def metadata(self) -> Optional[StructuredDatasetMetadata]: + return self._metadata + + def to_flyte_idl(self) -> _literals_pb2.StructuredDataset: + return _literals_pb2.StructuredDataset( + uri=self.uri, metadata=self.metadata.to_flyte_idl() if self.metadata else None + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _literals_pb2.StructuredDataset) -> "StructuredDataset": + return cls(uri=pb2_object.uri, metadata=StructuredDatasetMetadata.from_flyte_idl(pb2_object.metadata)) + + +class LiteralCollection(_common.FlyteIdlEntity): + def __init__(self, literals): + """ + :param list[Literal] literals: underlying list of literals in this collection. + """ + self._literals = literals + + @property + def literals(self): + """ + :rtype: list[Literal] + """ + return self._literals + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.LiteralCollection + """ + return _literals_pb2.LiteralCollection(literals=[l.to_flyte_idl() for l in self.literals]) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.LiteralCollection pb2_object: + :rtype: LiteralCollection + """ + return cls([Literal.from_flyte_idl(l) for l in pb2_object.literals]) + + +class LiteralMap(_common.FlyteIdlEntity): + def __init__(self, literals): + """ + :param dict[Text, Literal] literals: A dictionary mapping Text key names to Literal objects. + """ + self._literals = literals + + @property + def literals(self): + """ + A dictionary mapping Text key names to Literal objects. + :rtype: dict[Text, Literal] + """ + return self._literals + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.LiteralMap + """ + return _literals_pb2.LiteralMap(literals={k: v.to_flyte_idl() for k, v in self.literals.items()}) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.LiteralMap pb2_object: + :rtype: LiteralMap + """ + return cls({k: Literal.from_flyte_idl(v) for k, v in pb2_object.literals.items()}) + + +class Scalar(_common.FlyteIdlEntity): + def __init__( + self, + primitive: Primitive = None, + blob: Blob = None, + binary: Binary = None, + schema: Schema = None, + union: Union = None, + none_type: Void = None, + error: Error = None, + generic: Struct = None, + structured_dataset: StructuredDataset = None, + ): + """ + Scalar wrapper around Flyte types. Only one can be specified. + + :param Primitive primitive: + :param Blob blob: + :param Binary binary: + :param Schema schema: + :param Void none_type: + :param Error error: + :param google.protobuf.struct_pb2.Struct generic: + :param StructuredDataset structured_dataset: + """ + + self._primitive = primitive + self._blob = blob + self._binary = binary + self._schema = schema + self._union = union + self._none_type = none_type + self._error = error + self._generic = generic + self._structured_dataset = structured_dataset + + @property + def primitive(self): + """ + :rtype: Primitive + """ + return self._primitive + + @property + def blob(self): + """ + :rtype: Blob + """ + return self._blob + + @property + def binary(self): + """ + :rtype: Binary + """ + return self._binary + + @property + def schema(self): + """ + :rtype: Schema + """ + return self._schema + + @property + def union(self): + """ + :rtype: Union + """ + return self._union + + @property + def none_type(self): + """ + :rtype: Void + """ + return self._none_type + + @property + def error(self): + """ + :rtype: Error + """ + return self._error + + @property + def generic(self): + """ + :rtype: google.protobuf.struct_pb2.Struct + """ + return self._generic + + @property + def structured_dataset(self) -> StructuredDataset: + return self._structured_dataset + + @property + def value(self): + """ + Returns whichever value is set + :rtype: T + """ + return ( + self.primitive + or self.blob + or self.binary + or self.schema + or self.union + or self.none_type + or self.error + or self.generic + or self.structured_dataset + ) + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Scalar + """ + return _literals_pb2.Scalar( + primitive=self.primitive.to_flyte_idl() if self.primitive is not None else None, + blob=self.blob.to_flyte_idl() if self.blob is not None else None, + binary=self.binary.to_flyte_idl() if self.binary is not None else None, + schema=self.schema.to_flyte_idl() if self.schema is not None else None, + union=self.union.to_flyte_idl() if self.union is not None else None, + none_type=self.none_type.to_flyte_idl() if self.none_type is not None else None, + error=self.error.to_flyte_idl() if self.error is not None else None, + generic=self.generic, + structured_dataset=self.structured_dataset.to_flyte_idl() if self.structured_dataset is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.Scalar pb2_object: + :rtype: flytekit.models.literals.Scalar + """ + # todo finish + return cls( + primitive=Primitive.from_flyte_idl(pb2_object.primitive) if pb2_object.HasField("primitive") else None, + blob=Blob.from_flyte_idl(pb2_object.blob) if pb2_object.HasField("blob") else None, + binary=Binary.from_flyte_idl(pb2_object.binary) if pb2_object.HasField("binary") else None, + schema=Schema.from_flyte_idl(pb2_object.schema) if pb2_object.HasField("schema") else None, + union=Union.from_flyte_idl(pb2_object.union) if pb2_object.HasField("union") else None, + none_type=Void.from_flyte_idl(pb2_object.none_type) if pb2_object.HasField("none_type") else None, + error=pb2_object.error if pb2_object.HasField("error") else None, + generic=pb2_object.generic if pb2_object.HasField("generic") else None, + structured_dataset=StructuredDataset.from_flyte_idl(pb2_object.structured_dataset) + if pb2_object.HasField("structured_dataset") + else None, + ) + + +class Literal(_common.FlyteIdlEntity): + def __init__( + self, + scalar: Optional[Scalar] = None, + collection: Optional[LiteralCollection] = None, + map: Optional[LiteralMap] = None, + hash: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, + ): + """ + This IDL message represents a literal value in the Flyte ecosystem. + + :param Scalar scalar: + :param LiteralCollection collection: + :param LiteralMap map: + """ + self._scalar = scalar + self._collection = collection + self._map = map + self._hash = hash + self._metadata = metadata + + @property + def scalar(self): + """ + If not None, this value holds a scalar value which can be further unpacked. + :rtype: Scalar + """ + return self._scalar + + @property + def collection(self): + """ + If not None, this value holds a collection of Literal values which can be further unpacked. + :rtype: LiteralCollection + """ + return self._collection + + @property + def map(self): + """ + If not None, this value holds a map of Literal values which can be further unpacked. + :rtype: LiteralMap + """ + return self._map + + @property + def value(self): + """ + Returns one of the scalar, collection, or map properties based on which one is set. + :rtype: T + """ + return self.scalar or self.collection or self.map + + @property + def hash(self): + """ + If not None, this value holds a hash that represents the literal for caching purposes. + :rtype: str + """ + return self._hash + + @hash.setter + def hash(self, value): + self._hash = value + + @property + def metadata(self) -> Optional[Dict[str, str]]: + """ + This value holds metadata about the literal. + """ + return self._metadata + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.literals_pb2.Literal + """ + return _literals_pb2.Literal( + scalar=self.scalar.to_flyte_idl() if self.scalar is not None else None, + collection=self.collection.to_flyte_idl() if self.collection is not None else None, + map=self.map.to_flyte_idl() if self.map is not None else None, + hash=self.hash, + metadata=self.metadata, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.literals_pb2.Literal pb2_object: + :rtype: Literal + """ + collection = None + if pb2_object.HasField("collection"): + collection = LiteralCollection.from_flyte_idl(pb2_object.collection) + + return cls( + scalar=Scalar.from_flyte_idl(pb2_object.scalar) if pb2_object.HasField("scalar") else None, + collection=collection, + map=LiteralMap.from_flyte_idl(pb2_object.map) if pb2_object.HasField("map") else None, + hash=pb2_object.hash if pb2_object.hash else None, + metadata={k: v for k, v in pb2_object.metadata.items()} if pb2_object.metadata else None, + ) diff --git a/flytekit/flytekit/models/matchable_resource.py b/flytekit/flytekit/models/matchable_resource.py new file mode 100644 index 0000000000..64247f5bf5 --- /dev/null +++ b/flytekit/flytekit/models/matchable_resource.py @@ -0,0 +1,362 @@ +from flyteidl.admin import matchable_resource_pb2 as _matchable_resource + +from flytekit.models import common as _common + + +class MatchableResource(object): + TASK_RESOURCE = _matchable_resource.TASK_RESOURCE + CLUSTER_RESOURCE = _matchable_resource.CLUSTER_RESOURCE + EXECUTION_QUEUE = _matchable_resource.EXECUTION_QUEUE + EXECUTION_CLUSTER_LABEL = _matchable_resource.EXECUTION_CLUSTER_LABEL + QUALITY_OF_SERVICE_SPECIFICATION = _matchable_resource.QUALITY_OF_SERVICE_SPECIFICATION + PLUGIN_OVERRIDE = _matchable_resource.PLUGIN_OVERRIDE + + @classmethod + def enum_to_string(cls, val): + """ + :param int val: + :rtype: Text + """ + if val == cls.TASK_RESOURCE: + return "TASK_RESOURCE" + elif val == cls.CLUSTER_RESOURCE: + return "CLUSTER_RESOURCE" + elif val == cls.EXECUTION_QUEUE: + return "EXECUTION_QUEUE" + elif val == cls.EXECUTION_CLUSTER_LABEL: + return "EXECUTION_CLUSTER_LABEL" + elif val == cls.QUALITY_OF_SERVICE_SPECIFICATION: + return "QUALITY_OF_SERVICE_SPECIFICATION" + else: + return "" + + @classmethod + def string_to_enum(cls, val): + """ + :param Text val: + :rtype: int + """ + if val == "TASK_RESOURCE": + return cls.TASK_RESOURCE + elif val == "CLUSTER_RESOURCE": + return cls.CLUSTER_RESOURCE + elif val == "EXECUTION_QUEUE": + return cls.EXECUTION_QUEUE + elif val == "EXECUTION_CLUSTER_LABEL": + return cls.EXECUTION_CLUSTER_LABEL + elif val == cls.QUALITY_OF_SERVICE_SPECIFICATION: + return "QUALITY_OF_SERVICE_SPECIFICATION" + else: + return "" + + +class ClusterResourceAttributes(_common.FlyteIdlEntity): + def __init__(self, attributes): + """ + Custom resource attributes which will be applied in cluster resource creation (e.g. quotas). + Dict keys are the *case-sensitive* names of variables in templatized resource files. + Dict values should be the custom values which get substituted during resource creation. + + :param dict[Text, Text] attributes: Applied in cluster resource creation (e.g. quotas). + """ + self._attributes = attributes + + @property + def attributes(self): + """ + Custom resource attributes which will be applied in cluster resource management + :rtype: dict[Text, Text] + """ + return self._attributes + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.matchable_resource_pb2.ClusterResourceAttributes + """ + return _matchable_resource.ClusterResourceAttributes( + attributes=self.attributes, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.matchable_resource_pb2.ClusterResourceAttributes pb2_object: + :rtype: ClusterResourceAttributes + """ + return cls( + attributes=pb2_object.attributes, + ) + + +class ExecutionQueueAttributes(_common.FlyteIdlEntity): + def __init__(self, tags): + """ + Tags used for assigning execution queues for tasks matching a project, domain and optionally, workflow. + + :param list[Text] tags: + """ + self._tags = tags + + @property + def tags(self): + """ + :rtype: list[Text] + """ + return self._tags + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.matchable_resource_pb2.ExecutionQueueAttributes + """ + return _matchable_resource.ExecutionQueueAttributes( + tags=self.tags, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.matchable_resource_pb2.ExecutionQueueAttributes pb2_object: + :rtype: ExecutionQueueAttributes + """ + return cls( + tags=pb2_object.tags, + ) + + +class ExecutionClusterLabel(_common.FlyteIdlEntity): + def __init__(self, value): + """ + Label value to determine where the execution will be run + + :param Text value: + """ + self._value = value + + @property + def value(self): + """ + :rtype: Text + """ + return self._value + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.matchable_resource_pb2.ExecutionClusterLabel + """ + return _matchable_resource.ExecutionClusterLabel( + value=self.value, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.matchable_resource_pb2.ExecutionClusterLabel pb2_object: + :rtype: ExecutionClusterLabel + """ + return cls( + value=pb2_object.value, + ) + + +class PluginOverride(_common.FlyteIdlEntity): + FAIL = _matchable_resource.PluginOverride.FAIL + USE_DEFAULT = _matchable_resource.PluginOverride.USE_DEFAULT + + @classmethod + def string_to_enum(cls, val): + """ + :param Text val: + :rtype: int + """ + if val == "FAIL": + return cls.FAIL + elif val == "USE_DEFAULT": + return cls.USE_DEFAULT + else: + return "" + + def __init__(self, task_type, plugin_id, missing_plugin_behavior): + """ + Alternate plugin implementations requested for a specific task type. + + :param Text task_type: + :param: list[Text] plugin_id: + :param int missing_plugin_behavior + """ + self._task_type = task_type + self._plugin_id = plugin_id + self._missing_plugin_behavior = missing_plugin_behavior + + @property + def task_type(self): + """ + :rtype: Text + """ + return self._task_type + + @property + def plugin_id(self): + """ + :rtype: list[Text] + """ + return self._plugin_id + + @property + def missing_plugin_behavior(self): + """ + :rtype: int + """ + return self._missing_plugin_behavior + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.matchable_resource_pb2.PluginOverride + """ + return _matchable_resource.PluginOverride( + task_type=self.task_type, + plugin_id=self.plugin_id, + missing_plugin_behavior=self.missing_plugin_behavior, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.matchable_resource_pb2.PluginOverride pb2_object: + :rtype: PluginOverride + """ + return cls( + task_type=pb2_object.task_type, + plugin_id=pb2_object.plugin_id, + missing_plugin_behavior=pb2_object.missing_plugin_behavior, + ) + + +class PluginOverrides(_common.FlyteIdlEntity): + def __init__(self, overrides): + """ + Alternate plugin implementations for designated task types. + + :param list[PluginOverride] overrides: + """ + self._overrides = overrides + + @property + def overrides(self): + """ + :rtype: list[PluginOverride] + """ + return self._overrides + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.matchable_resource_pb2.PluginOverrides + """ + return _matchable_resource.PluginOverrides(overrides=[override.to_flyte_idl() for override in self.overrides]) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.matchable_resource_pb2.PluginOverrides pb2_object: + :rtype: PluginOverrides + """ + return cls(overrides=[PluginOverride.from_flyte_idl(override) for override in pb2_object.overrides]) + + +class MatchingAttributes(_common.FlyteIdlEntity): + def __init__( + self, + cluster_resource_attributes=None, + execution_queue_attributes=None, + execution_cluster_label=None, + plugin_overrides=None, + ): + """ + At most one target from cluster_resource_attributes, execution_queue_attributes or execution_cluster_label + can be set. + :param ClusterResourceAttributes cluster_resource_attributes: + :param ExecutionQueueAttributes execution_queue_attributes: + :param ExecutionClusterLabel execution_cluster_label: + :param PluginOverrides plugin_overrides: + """ + if cluster_resource_attributes: + if execution_queue_attributes or execution_cluster_label or plugin_overrides: + raise ValueError("Only one target can be set") + elif execution_queue_attributes and (execution_cluster_label or plugin_overrides): + raise ValueError("Only one target can be set") + elif execution_cluster_label and plugin_overrides: + raise ValueError("Only one target can be set") + + self._cluster_resource_attributes = cluster_resource_attributes + self._execution_queue_attributes = execution_queue_attributes + self._execution_cluster_label = execution_cluster_label + self._plugin_overrides = plugin_overrides + + @property + def cluster_resource_attributes(self): + """ + Custom resource attributes which will be applied in cluster resource creation (e.g. quotas). + :rtype: ClusterResourceAttributes + """ + return self._cluster_resource_attributes + + @property + def execution_queue_attributes(self): + """ + Tags used for assigning execution queues for tasks. + :rtype: ExecutionQueueAttributes + """ + return self._execution_queue_attributes + + @property + def execution_cluster_label(self): + """ + Label value to determine where the execution will be run. + :rtype: ExecutionClusterLabel + """ + return self._execution_cluster_label + + @property + def plugin_overrides(self): + """ + Plugin implementation overrides for specific task types. + :rtype: PluginOverrides + """ + return self._plugin_overrides + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.matchable_resource_pb2.MatchingAttributes + """ + return _matchable_resource.MatchingAttributes( + cluster_resource_attributes=self.cluster_resource_attributes.to_flyte_idl() + if self.cluster_resource_attributes + else None, + execution_queue_attributes=self.execution_queue_attributes.to_flyte_idl() + if self.execution_queue_attributes + else None, + execution_cluster_label=self.execution_cluster_label.to_flyte_idl() + if self.execution_cluster_label + else None, + plugin_overrides=self.plugin_overrides.to_flyte_idl() if self.plugin_overrides else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.matchable_resource_pb2.MatchingAttributes pb2_object: + :rtype: MatchingAttributes + """ + return cls( + cluster_resource_attributes=ClusterResourceAttributes.from_flyte_idl(pb2_object.cluster_resource_attributes) + if pb2_object.HasField("cluster_resource_attributes") + else None, + execution_queue_attributes=ExecutionQueueAttributes.from_flyte_idl(pb2_object.execution_queue_attributes) + if pb2_object.HasField("execution_queue_attributes") + else None, + execution_cluster_label=ExecutionClusterLabel.from_flyte_idl(pb2_object.execution_cluster_label) + if pb2_object.HasField("execution_cluster_label") + else None, + plugin_overrides=PluginOverrides.from_flyte_idl(pb2_object.plugin_overrides) + if pb2_object.HasField("plugin_overrides") + else None, + ) diff --git a/flytekit/flytekit/models/named_entity.py b/flytekit/flytekit/models/named_entity.py new file mode 100644 index 0000000000..63dd598d98 --- /dev/null +++ b/flytekit/flytekit/models/named_entity.py @@ -0,0 +1,122 @@ +from flyteidl.admin import common_pb2 as _common + +from flytekit.models import common as _common_models + + +class NamedEntityState(object): + ACTIVE = _common.NAMED_ENTITY_ACTIVE + ARCHIVED = _common.NAMED_ENTITY_ARCHIVED + + @classmethod + def enum_to_string(cls, val): + """ + :param int val: + :rtype: Text + """ + if val == cls.ACTIVE: + return "ACTIVE" + elif val == cls.ARCHIVED: + return "ARCHIVED" + else: + return "" + + +class NamedEntityIdentifier(_common_models.FlyteIdlEntity): + def __init__(self, project, domain, name): + """ + :param Text project: + :param Text domain: + :param Text name: + """ + self._project = project + self._domain = domain + self._name = name + + @property + def project(self): + """ + :rtype: Text + """ + return self._project + + @property + def domain(self): + """ + :rtype: Text + """ + return self._domain + + @property + def name(self): + """ + :rtype: Text + """ + return self._name + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.NamedEntityIdentifier + """ + return _common.NamedEntityIdentifier( + project=self.project, + domain=self.domain, + name=self.name, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.common_pb2.NamedEntityIdentifier p: + :rtype: Identifier + """ + return cls( + project=p.project, + domain=p.domain, + name=p.name, + ) + + +class NamedEntityMetadata(_common_models.FlyteIdlEntity): + def __init__(self, description, state): + """ + + :param Text description: + :param int state: enum value from NamedEntityState + """ + self._description = description + self._state = state + + @property + def description(self): + """ + :rtype: Text + """ + return self._description + + @property + def state(self): + """ + enum value from NamedEntityState + :rtype: int + """ + return self._state + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.common_pb2.NamedEntityMetadata + """ + return _common.NamedEntityMetadata( + description=self.description, + state=self.state, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.core.common_pb2.NamedEntityMetadata p: + :rtype: Identifier + """ + return cls( + description=p.description, + state=p.state, + ) diff --git a/flytekit/flytekit/models/node_execution.py b/flytekit/flytekit/models/node_execution.py new file mode 100644 index 0000000000..50e685f3e8 --- /dev/null +++ b/flytekit/flytekit/models/node_execution.py @@ -0,0 +1,317 @@ +import datetime +import typing +from datetime import timezone as _timezone + +import flyteidl.admin.node_execution_pb2 as _node_execution_pb2 + +from flytekit.models import common as _common_models +from flytekit.models.core import catalog as catalog_models +from flytekit.models.core import compiler as core_compiler_models +from flytekit.models.core import execution as _core_execution +from flytekit.models.core import identifier as _identifier + + +class WorkflowNodeMetadata(_common_models.FlyteIdlEntity): + def __init__(self, execution_id: _identifier.WorkflowExecutionIdentifier): + self._execution_id = execution_id + + @property + def execution_id(self) -> _identifier.WorkflowExecutionIdentifier: + return self._execution_id + + def to_flyte_idl(self) -> _node_execution_pb2.WorkflowNodeMetadata: + return _node_execution_pb2.WorkflowNodeMetadata( + executionId=self.execution_id.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, p: _node_execution_pb2.WorkflowNodeMetadata) -> "WorkflowNodeMetadata": + return cls( + execution_id=_identifier.WorkflowExecutionIdentifier.from_flyte_idl(p.executionId), + ) + + +class DynamicWorkflowNodeMetadata(_common_models.FlyteIdlEntity): + def __init__(self, id: _identifier.Identifier, compiled_workflow: core_compiler_models.CompiledWorkflowClosure): + self._id = id + self._compiled_workflow = compiled_workflow + + @property + def id(self) -> _identifier.Identifier: + return self._id + + @property + def compiled_workflow(self) -> core_compiler_models.CompiledWorkflowClosure: + return self._compiled_workflow + + def to_flyte_idl(self) -> _node_execution_pb2.DynamicWorkflowNodeMetadata: + return _node_execution_pb2.DynamicWorkflowNodeMetadata( + id=self.id.to_flyte_idl(), + compiled_workflow=self.compiled_workflow.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, p: _node_execution_pb2.DynamicWorkflowNodeMetadata) -> "DynamicWorkflowNodeMetadata": + yy = cls( + id=_identifier.Identifier.from_flyte_idl(p.id), + compiled_workflow=core_compiler_models.CompiledWorkflowClosure.from_flyte_idl(p.compiled_workflow), + ) + return yy + + +class TaskNodeMetadata(_common_models.FlyteIdlEntity): + def __init__(self, cache_status: int, catalog_key: catalog_models.CatalogMetadata): + self._cache_status = cache_status + self._catalog_key = catalog_key + + @property + def cache_status(self) -> int: + return self._cache_status + + @property + def catalog_key(self) -> catalog_models.CatalogMetadata: + return self._catalog_key + + def to_flyte_idl(self) -> _node_execution_pb2.TaskNodeMetadata: + return _node_execution_pb2.TaskNodeMetadata( + cache_status=self.cache_status, + catalog_key=self.catalog_key.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, p: _node_execution_pb2.TaskNodeMetadata) -> "TaskNodeMetadata": + return cls( + cache_status=p.cache_status, + catalog_key=catalog_models.CatalogMetadata.from_flyte_idl(p.catalog_key), + ) + + +class NodeExecutionClosure(_common_models.FlyteIdlEntity): + def __init__( + self, + phase, + started_at, + duration, + output_uri=None, + deck_uri=None, + error=None, + workflow_node_metadata: typing.Optional[WorkflowNodeMetadata] = None, + task_node_metadata: typing.Optional[TaskNodeMetadata] = None, + created_at: typing.Optional[datetime.datetime] = None, + updated_at: typing.Optional[datetime.datetime] = None, + ): + """ + :param int phase: + :param datetime.datetime started_at: + :param datetime.timedelta duration: + :param Text output_uri: + :param flytekit.models.core.execution.ExecutionError error: + """ + self._phase = phase + self._started_at = started_at + self._duration = duration + self._output_uri = output_uri + self._deck_uri = deck_uri + self._error = error + self._workflow_node_metadata = workflow_node_metadata + self._task_node_metadata = task_node_metadata + # TODO: Add output_data field as well. + self._created_at = created_at + self._updated_at = updated_at + + @property + def phase(self): + """ + :rtype: int + """ + return self._phase + + @property + def started_at(self): + """ + :rtype: datetime.datetime + """ + return self._started_at + + @property + def duration(self): + """ + :rtype: datetime.timedelta + """ + return self._duration + + @property + def created_at(self) -> typing.Optional[datetime.datetime]: + return self._created_at + + @property + def updated_at(self) -> typing.Optional[datetime.datetime]: + return self._updated_at + + @property + def output_uri(self): + """ + :rtype: Text + """ + return self._output_uri + + @property + def deck_uri(self): + """ + :rtype: str + """ + return self._deck_uri + + @property + def error(self): + """ + :rtype: flytekit.models.core.execution.ExecutionError + """ + return self._error + + @property + def workflow_node_metadata(self) -> typing.Optional[WorkflowNodeMetadata]: + return self._workflow_node_metadata + + @property + def task_node_metadata(self) -> typing.Optional[TaskNodeMetadata]: + return self._task_node_metadata + + @property + def target_metadata(self) -> typing.Union[WorkflowNodeMetadata, TaskNodeMetadata]: + return self.workflow_node_metadata or self.task_node_metadata + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.node_execution_pb2.NodeExecutionClosure + """ + obj = _node_execution_pb2.NodeExecutionClosure( + phase=self.phase, + output_uri=self.output_uri, + deck_uri=self.deck_uri, + error=self.error.to_flyte_idl() if self.error is not None else None, + workflow_node_metadata=self.workflow_node_metadata.to_flyte_idl() + if self.workflow_node_metadata is not None + else None, + task_node_metadata=self.task_node_metadata.to_flyte_idl() if self.task_node_metadata is not None else None, + ) + obj.started_at.FromDatetime(self.started_at.astimezone(_timezone.utc).replace(tzinfo=None)) + obj.duration.FromTimedelta(self.duration) + if self.created_at: + obj.created_at.FromDatetime(self.created_at.astimezone(_timezone.utc).replace(tzinfo=None)) + if self.updated_at: + obj.updated_at.FromDatetime(self.updated_at.astimezone(_timezone.utc).replace(tzinfo=None)) + return obj + + @classmethod + def from_flyte_idl(cls, p): + """ + :param flyteidl.admin.node_execution_pb2.NodeExecutionClosure p: + :rtype: NodeExecutionClosure + """ + return cls( + phase=p.phase, + output_uri=p.output_uri if p.HasField("output_uri") else None, + deck_uri=p.deck_uri, + error=_core_execution.ExecutionError.from_flyte_idl(p.error) if p.HasField("error") else None, + started_at=p.started_at.ToDatetime().replace(tzinfo=_timezone.utc), + duration=p.duration.ToTimedelta(), + workflow_node_metadata=WorkflowNodeMetadata.from_flyte_idl(p.workflow_node_metadata) + if p.HasField("workflow_node_metadata") + else None, + task_node_metadata=TaskNodeMetadata.from_flyte_idl(p.task_node_metadata) + if p.HasField("task_node_metadata") + else None, + created_at=p.created_at.ToDatetime().replace(tzinfo=_timezone.utc) if p.HasField("created_at") else None, + updated_at=p.updated_at.ToDatetime().replace(tzinfo=_timezone.utc) if p.HasField("updated_at") else None, + ) + + +class NodeExecutionMetaData(_common_models.FlyteIdlEntity): + def __init__(self, retry_group: str, is_parent_node: bool, spec_node_id: str): + self._retry_group = retry_group + self._is_parent_node = is_parent_node + self._spec_node_id = spec_node_id + + @property + def retry_group(self) -> str: + return self._retry_group + + @property + def is_parent_node(self) -> bool: + return self._is_parent_node + + @property + def spec_node_id(self) -> str: + return self._spec_node_id + + def to_flyte_idl(self) -> _node_execution_pb2.NodeExecutionMetaData: + return _node_execution_pb2.NodeExecutionMetaData( + retry_group=self.retry_group, + is_parent_node=self.is_parent_node, + spec_node_id=self.spec_node_id, + ) + + @classmethod + def from_flyte_idl(cls, p: _node_execution_pb2.NodeExecutionMetaData) -> "NodeExecutionMetaData": + return cls( + retry_group=p.retry_group, + is_parent_node=p.is_parent_node, + spec_node_id=p.spec_node_id, + ) + + +class NodeExecution(_common_models.FlyteIdlEntity): + def __init__(self, id, input_uri, closure, metadata): + """ + :param flytekit.models.core.identifier.NodeExecutionIdentifier id: + :param Text input_uri: + :param NodeExecutionClosure closure: + :param NodeExecutionMetaData metadata: + """ + self._id = id + self._input_uri = input_uri + self._closure = closure + self._metadata = metadata + + @property + def id(self): + """ + :rtype: flytekit.models.core.identifier.NodeExecutionIdentifier + """ + return self._id + + @property + def input_uri(self): + """ + :rtype: Text + """ + return self._input_uri + + @property + def closure(self): + """ + :rtype: NodeExecutionClosure + """ + return self._closure + + @property + def metadata(self) -> NodeExecutionMetaData: + return self._metadata + + def to_flyte_idl(self) -> _node_execution_pb2.NodeExecution: + return _node_execution_pb2.NodeExecution( + id=self.id.to_flyte_idl(), + input_uri=self.input_uri, + closure=self.closure.to_flyte_idl(), + metadata=self.metadata.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, p: _node_execution_pb2.NodeExecution) -> "NodeExecution": + return cls( + id=_identifier.NodeExecutionIdentifier.from_flyte_idl(p.id), + input_uri=p.input_uri, + closure=NodeExecutionClosure.from_flyte_idl(p.closure), + metadata=NodeExecutionMetaData.from_flyte_idl(p.metadata), + ) diff --git a/flytekit/flytekit/models/presto.py b/flytekit/flytekit/models/presto.py new file mode 100644 index 0000000000..b5d13f7288 --- /dev/null +++ b/flytekit/flytekit/models/presto.py @@ -0,0 +1,80 @@ +""" +This is a deprecated module. Model files for plugins should go alongside the microlib. +See ``plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/models.py`` as an example. +""" + +## Todo - change this to qubole_presto once Luis's PR gets merged +# from flyteidl.plugins import qubole_presto as _qubole +from flyteidl.plugins import presto_pb2 as _presto + +from flytekit.models import common as _common + + +class PrestoQuery(_common.FlyteIdlEntity): + def __init__(self, routing_group=None, catalog=None, schema=None, statement=None): + """ + Initializes a new PrestoQuery. + + :param string routing_group: + :param string catalog: + :param string schema: + :param string statement: + + """ + self._routing_group = routing_group + self._catalog = catalog + self._schema = schema + self._statement = statement + + @property + def routing_group(self): + """ + The query string. + :rtype: str + """ + return self._routing_group + + @property + def catalog(self): + """ + :rtype: int + """ + return self._catalog + + @property + def schema(self): + """ + :rtype: int + """ + return self._schema + + @property + def statement(self): + """ + :rtype: int + """ + return self._statement + + def to_flyte_idl(self): + """ + :rtype: _presto.PrestoQuery + """ + return _presto.PrestoQuery( + routing_group=self._routing_group, + catalog=self._catalog, + schema=self._schema, + statement=self._statement, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param _presto.PrestoQuery pb2_object: + :return: PrestoQuery + """ + return cls( + routing_group=pb2_object.routing_group, + catalog=pb2_object.catalog, + schema=pb2_object.schema, + statement=pb2_object.statement, + ) diff --git a/flytekit/flytekit/models/project.py b/flytekit/flytekit/models/project.py new file mode 100644 index 0000000000..57f4f1901a --- /dev/null +++ b/flytekit/flytekit/models/project.py @@ -0,0 +1,78 @@ +from flyteidl.admin import project_pb2 as _project_pb2 + +from flytekit.models import common as _common + + +class Project(_common.FlyteIdlEntity): + class ProjectState(object): + ACTIVE = _project_pb2.Project.ACTIVE + ARCHIVED = _project_pb2.Project.ARCHIVED + SYSTEM_GENERATED = _project_pb2.Project.SYSTEM_GENERATED + + def __init__(self, id, name, description, state=ProjectState.ACTIVE): + """ + A project represents a logical grouping used to organize entities (tasks, workflows, executions) in the Flyte + platform. + + :param Text id: A globally unique identifier associated with this project. + :param Text name: A human-readable name for this project. + :param Text name: A concise description for this project. + """ + self._id = id + self._name = name + self._description = description + self._state = state + + @classmethod + def archived_project(cls, id): + return cls(id, "", "", cls.ProjectState.ARCHIVED) + + @classmethod + def active_project(cls, id): + return cls(id, "", "", cls.ProjectState.ACTIVE) + + @property + def id(self): + """ + A globally unique identifier associated with this project + :rtype: Text + """ + return self._id + + @property + def name(self): + """ + A human-readable name for this project. + :rtype: Text + """ + return self._name + + @property + def description(self): + """ + A concise description for this project. + :rtype: Text + """ + return self._description + + @property + def state(self): + """ + The state of this project. + :rtype: int + """ + return self._state + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.project_pb2.Project + """ + return _project_pb2.Project(id=self.id, name=self.name, description=self.description, state=self._state) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.project_pb2.Project pb2_object: + :rtype: Project + """ + return cls(id=pb2_object.id, name=pb2_object.name, description=pb2_object.description, state=pb2_object.state) diff --git a/flytekit/flytekit/models/qubole.py b/flytekit/flytekit/models/qubole.py new file mode 100644 index 0000000000..97d4c0d3b1 --- /dev/null +++ b/flytekit/flytekit/models/qubole.py @@ -0,0 +1,170 @@ +""" +This is a deprecated module. Model files for plugins should go alongside the microlib. +See ``plugins/flytekit-kf-pytorch/flytekitplugins/kfpytorch/models.py`` as an example. +""" + +from flyteidl.plugins import qubole_pb2 as _qubole + +from flytekit.models import common as _common + + +class HiveQuery(_common.FlyteIdlEntity): + def __init__(self, query, timeout_sec, retry_count): + """ + Initializes a new HiveQuery. + + :param Text query: The query string. + :param int timeout_sec: + :param int retry_count: + + """ + self._query = query + self._timeout_sec = timeout_sec + self._retry_count = retry_count + + @property + def query(self): + """ + The query string. + :rtype: str + """ + return self._query + + @property + def timeout_sec(self): + """ + :rtype: int + """ + return self._timeout_sec + + @property + def retry_count(self): + """ + :rtype: int + """ + return self._retry_count + + def to_flyte_idl(self): + """ + :rtype: _qubole.HiveQuery + """ + return _qubole.HiveQuery(query=self.query, timeout_sec=self.timeout_sec, retryCount=self.retry_count) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param _qubole.HiveQuery pb2_object: + :return: HiveQuery + """ + return cls( + query=pb2_object.query, + timeout_sec=pb2_object.timeout_sec, + retry_count=pb2_object.retryCount, + ) + + +class HiveQueryCollection(_common.FlyteIdlEntity): + def __init__(self, queries): + """ + Initializes a new HiveQueryCollection. + + :param list[HiveQuery] queries: Queries to execute. + """ + self._queries = queries + + @property + def queries(self): + """ + :rtype: list[HiveQuery] + """ + return self._queries + + def to_flyte_idl(self): + """ + :rtype: _qubole.HiveQueryCollection + """ + return _qubole.HiveQueryCollection( + queries=[query.to_flyte_idl() for query in self.queries] if self.queries else None + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param _qubole.HiveQuery pb2_object: + :rtype: HiveQueryCollection + """ + return cls(queries=[HiveQuery.from_flyte_idl(query) for query in pb2_object.queries]) + + +class QuboleHiveJob(_common.FlyteIdlEntity): + def __init__(self, query, cluster_label, tags, query_collection=None): + """ + Initializes a HiveJob. + + :param HiveQuery query: Single query to execute + :param Text cluster_label: The qubole cluster label to execute the query on + :param list[Text] tags: User tags for the queries + :param HiveQueryCollection query_collection: Deprecated Queries to execute. + """ + self._query = query + self._cluster_label = cluster_label + self._tags = tags + self._query_collection = query_collection + + @property + def query_collection(self): + """ + The queries to be executed + :rtype: HiveQueryCollection + """ + return self._query_collection + + @property + def query(self): + """ + The query to be executed + :rtype: HiveQuery + """ + return self._query + + @property + def cluster_label(self): + """ + The cluster label where the query should be executed + :rtype: Text + """ + return self._cluster_label + + @property + def tags(self): + """ + User tags for the queries + :rtype: list[Text] + """ + return self._tags + + def to_flyte_idl(self): + """ + :rtype: _qubole.QuboleHiveJob + """ + return _qubole.QuboleHiveJob( + query_collection=self._query_collection.to_flyte_idl() if self._query_collection else None, + query=self._query.to_flyte_idl() if self._query else None, + cluster_label=self._cluster_label, + tags=self._tags, + ) + + @classmethod + def from_flyte_idl(cls, p): + """ + :param _qubole.QuboleHiveJob p: + :rtype: QuboleHiveJob + """ + return cls( + query_collection=HiveQueryCollection.from_flyte_idl(p.query_collection) + if p.HasField("query_collection") + else None, + query=HiveQuery.from_flyte_idl(p.query) if p.HasField("query") else None, + cluster_label=p.cluster_label, + tags=p.tags, + ) diff --git a/flytekit/flytekit/models/schedule.py b/flytekit/flytekit/models/schedule.py new file mode 100644 index 0000000000..a6be2a58ee --- /dev/null +++ b/flytekit/flytekit/models/schedule.py @@ -0,0 +1,169 @@ +from flyteidl.admin import schedule_pb2 as _schedule_pb2 + +from flytekit.models import common as _common + + +class Schedule(_common.FlyteIdlEntity): + class FixedRateUnit(object): + MINUTE = _schedule_pb2.MINUTE + HOUR = _schedule_pb2.HOUR + DAY = _schedule_pb2.DAY + + @classmethod + def enum_to_string(cls, int_value): + """ + :param int_value: + :rtype: Text + """ + if int_value == cls.MINUTE: + return "MINUTE" + elif int_value == cls.HOUR: + return "HOUR" + elif int_value == cls.DAY: + return "DAY" + else: + return "{}".format(int_value) + + class FixedRate(_common.FlyteIdlEntity): + def __init__(self, value, unit): + """ + :param int value: + :param int unit: enum value from FixedRateUnit + """ + self._value = value + self._unit = unit + + @property + def value(self): + """ + :rtype: int + """ + return self._value + + @property + def unit(self): + """ + :rtype: int + """ + return self._unit + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.schedule_pb2.FixedRate + """ + return _schedule_pb2.FixedRate(value=self.value, unit=self.unit) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.schedule_pb2.FixedRate pb2_object: + :rtype: Schedule.FixedRate + """ + return cls(pb2_object.value, pb2_object.unit) + + class CronSchedule(_common.FlyteIdlEntity): + def __init__(self, schedule, offset): + """ + :param Text schedule: cron expression or aliases + :param Text offset: ISO_8601 Duration + """ + self._schedule = schedule + self._offset = offset + + @property + def schedule(self): + """ + :rtype: Text + """ + return self._schedule + + @property + def offset(self): + """ + :rtype: Text + """ + return self._offset + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.schedule_pb2.FixedRate + """ + return _schedule_pb2.CronSchedule(schedule=self.schedule, offset=self.offset) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.schedule_pb2.CronSchedule pb2_object: + :rtype: Schedule.CronSchedule + """ + return cls(pb2_object.schedule or None, pb2_object.offset or None) + + def __init__(self, kickoff_time_input_arg, cron_expression=None, rate=None, cron_schedule=None): + """ + One of cron_expression or fixed rate must be specified. + + :param Text kickoff_time_input_arg: + :param Text cron_expression: [Optional] + :param Schedule.FixedRate rate: [Optional] + :param Schedule.CronSchedule cron_schedule: [Optional] + """ + self._kickoff_time_input_arg = kickoff_time_input_arg + self._cron_expression = cron_expression + self._rate = rate + self._cron_schedule = cron_schedule + + @property + def kickoff_time_input_arg(self): + return self._kickoff_time_input_arg + + @property + def cron_expression(self): + """ + :rtype: Text + """ + return self._cron_expression + + @property + def rate(self): + """ + :rtype: Schedule.FixedRate + """ + return self._rate + + @property + def cron_schedule(self): + """ + :rtype: Schedule.CronSchedule + """ + return self._cron_schedule + + @property + def schedule_expression(self): + return self.cron_expression or self.rate or self.cron_schedule + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.schedule_pb2.Schedule + """ + return _schedule_pb2.Schedule( + kickoff_time_input_arg=self.kickoff_time_input_arg, + cron_expression=self.cron_expression, + rate=self.rate.to_flyte_idl() if self.rate is not None else None, + cron_schedule=self.cron_schedule.to_flyte_idl() if self.cron_schedule is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.schedule_pb2.Schedule pb2_object: + :rtype: Schedule + """ + # Explicitly instantiate a Schedule model rather than a potential sub-class. + return Schedule( + pb2_object.kickoff_time_input_arg, + cron_expression=pb2_object.cron_expression if pb2_object.HasField("cron_expression") else None, + rate=Schedule.FixedRate.from_flyte_idl(pb2_object.rate) if pb2_object.HasField("rate") else None, + cron_schedule=Schedule.CronSchedule.from_flyte_idl(pb2_object.cron_schedule) + if pb2_object.HasField("cron_schedule") + else None, + ) diff --git a/flytekit/flytekit/models/security.py b/flytekit/flytekit/models/security.py new file mode 100644 index 0000000000..a9ee7e7cb9 --- /dev/null +++ b/flytekit/flytekit/models/security.py @@ -0,0 +1,175 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional + +from flyteidl.core import security_pb2 as _sec + +from flytekit.models import common as _common + + +@dataclass +class Secret(_common.FlyteIdlEntity): + """ + See :std:ref:`cookbook:secrets` for usage examples. + + Args: + group is the Name of the secret. For example in kubernetes secrets is the name of the secret + key is optional and can be an individual secret identifier within the secret For k8s this is required + version is the version of the secret. This is an optional field + mount_requirement provides a hint to the system as to how the secret should be injected + """ + + class MountType(Enum): + ANY = _sec.Secret.MountType.ANY + """ + Use this if the secret can be injected as either an environment variable / file and this should be left for the + platform to decide. This is the most flexible option + """ + ENV_VAR = _sec.Secret.MountType.ENV_VAR + """ + Use this if the secret can be injected as an environment variable. Usually works for symmetric keys, passwords etc + """ + FILE = _sec.Secret.MountType.FILE + """ + Use this for Secrets that cannot be injected into env-var or need to be available as a file + Caution: May not be supported in all environments + """ + + group: Optional[str] = None + key: Optional[str] = None + group_version: Optional[str] = None + mount_requirement: MountType = MountType.ANY + + def __post_init__(self): + from flytekit.configuration.plugin import get_plugin + + if get_plugin().secret_requires_group() and self.group is None: + raise ValueError("Group is a required parameter") + + def to_flyte_idl(self) -> _sec.Secret: + return _sec.Secret( + group=self.group, + group_version=self.group_version, + key=self.key, + mount_requirement=self.mount_requirement.value, + ) + + @classmethod + 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 if pb2_object.key else None, + mount_requirement=Secret.MountType(pb2_object.mount_requirement), + ) + + +@dataclass +class OAuth2Client(_common.FlyteIdlEntity): + client_id: str + client_secret: str + + def to_flyte_idl(self) -> _sec.OAuth2Client: + return _sec.OAuth2Client( + client_id=self.client_id, + client_secret=self.client_secret, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _sec.OAuth2Client) -> "OAuth2Client": + return cls( + client_id=pb2_object.client_id, + client_secret=pb2_object.client_secret, + ) + + +@dataclass +class Identity(_common.FlyteIdlEntity): + iam_role: Optional[str] = None + k8s_service_account: Optional[str] = None + oauth2_client: Optional[OAuth2Client] = None + + def to_flyte_idl(self) -> _sec.Identity: + return _sec.Identity( + iam_role=self.iam_role if self.iam_role else None, + k8s_service_account=self.k8s_service_account if self.k8s_service_account else None, + oauth2_client=self.oauth2_client.to_flyte_idl() if self.oauth2_client else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _sec.Identity) -> "Identity": + return cls( + iam_role=pb2_object.iam_role if pb2_object.iam_role else None, + k8s_service_account=pb2_object.k8s_service_account if pb2_object.k8s_service_account else None, + oauth2_client=OAuth2Client.from_flyte_idl(pb2_object.oauth2_client) + if pb2_object.oauth2_client and pb2_object.oauth2_client.ByteSize() + else None, + ) + + +@dataclass +class OAuth2TokenRequest(_common.FlyteIdlEntity): + class Type(Enum): + CLIENT_CREDENTIALS = _sec.OAuth2TokenRequest.Type.CLIENT_CREDENTIALS + + name: str + client: OAuth2Client + idp_discovery_endpoint: Optional[str] = None + token_endpoint: Optional[str] = None + type_: Type = Type.CLIENT_CREDENTIALS + + def to_flyte_idl(self) -> _sec.OAuth2TokenRequest: + return _sec.OAuth2TokenRequest( + name=self.name, + type=self.type_, + token_endpoint=self.token_endpoint, + idp_discovery_endpoint=self.idp_discovery_endpoint, + client=self.client.to_flyte_idl() if self.client else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _sec.OAuth2TokenRequest) -> "OAuth2TokenRequest": + return cls( + name=pb2_object.name, + idp_discovery_endpoint=pb2_object.idp_discovery_endpoint, + token_endpoint=pb2_object.token_endpoint, + type_=pb2_object.type, + client=OAuth2Client.from_flyte_idl(pb2_object.client) if pb2_object.HasField("client") else None, + ) + + +@dataclass +class SecurityContext(_common.FlyteIdlEntity): + """ + This is a higher level wrapper object that for the most part users shouldn't have to worry about. You should + be able to just use :py:class:`flytekit.Secret` instead. + """ + + run_as: Optional[Identity] = None + secrets: Optional[List[Secret]] = None + tokens: Optional[List[OAuth2TokenRequest]] = None + + def __post_init__(self): + if self.secrets and not isinstance(self.secrets, list): + self.secrets = [self.secrets] + if self.tokens and not isinstance(self.tokens, list): + self.tokens = [self.tokens] + + def to_flyte_idl(self) -> _sec.SecurityContext: + if self.run_as is None and self.secrets is None and self.tokens is None: + return None + return _sec.SecurityContext( + run_as=self.run_as.to_flyte_idl() if self.run_as else None, + secrets=[s.to_flyte_idl() for s in self.secrets] if self.secrets else None, + tokens=[t.to_flyte_idl() for t in self.tokens] if self.tokens else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _sec.SecurityContext) -> "SecurityContext": + return cls( + run_as=Identity.from_flyte_idl(pb2_object.run_as) + if pb2_object.run_as and pb2_object.run_as.ByteSize() > 0 + else None, + secrets=[Secret.from_flyte_idl(s) for s in pb2_object.secrets] if pb2_object.secrets else None, + tokens=[OAuth2TokenRequest.from_flyte_idl(t) for t in pb2_object.tokens] if pb2_object.tokens else None, + ) diff --git a/flytekit/flytekit/models/task.py b/flytekit/flytekit/models/task.py new file mode 100644 index 0000000000..1da786ea6d --- /dev/null +++ b/flytekit/flytekit/models/task.py @@ -0,0 +1,954 @@ +import json as _json +import typing + +from flyteidl.admin import task_pb2 as _admin_task +from flyteidl.core import compiler_pb2 as _compiler +from flyteidl.core import literals_pb2 as _literals_pb2 +from flyteidl.core import tasks_pb2 as _core_task +from google.protobuf import json_format as _json_format +from google.protobuf import struct_pb2 as _struct + +from flytekit.models import common as _common +from flytekit.models import interface as _interface +from flytekit.models import literals as _literals +from flytekit.models import security as _sec +from flytekit.models.core import identifier as _identifier +from flytekit.models.documentation import Documentation + + +class Resources(_common.FlyteIdlEntity): + class ResourceName(object): + UNKNOWN = _core_task.Resources.UNKNOWN + CPU = _core_task.Resources.CPU + GPU = _core_task.Resources.GPU + MEMORY = _core_task.Resources.MEMORY + EPHEMERAL_STORAGE = _core_task.Resources.EPHEMERAL_STORAGE + + class ResourceEntry(_common.FlyteIdlEntity): + def __init__(self, name, value): + """ + :param int name: enum value from ResourceName + :param Text value: a textual value describing the resource need. Must be a valid k8s quantity. + """ + self._name = name + self._value = value + + @property + def name(self): + """ + enum value from ResourceName + :rtype: int + """ + return self._name + + @property + def value(self): + """ + A textual value describing the resource need. Must be a valid k8s quantity. + :rtype: Text + """ + return self._value + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.tasks_pb2.ResourceEntry + """ + return _core_task.Resources.ResourceEntry(name=self.name, value=self.value) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.tasks_pb2.Resources.ResourceEntry pb2_object: + :rtype: Resources.ResourceEntry + """ + return cls(name=pb2_object.name, value=pb2_object.value) + + def __init__(self, requests, limits): + """ + :param list[Resources.ResourceEntry] requests: The desired resources for execution. This is given on a best + effort basis. + :param list[Resources.ResourceEntry] limits: These are the limits required. These are guaranteed to be + satisfied. + """ + self._requests = requests + self._limits = limits + + @property + def requests(self): + """ + The desired resources for execution. This is given on a best effort basis. + :rtype: list[Resources.ResourceEntry] + """ + return self._requests + + @property + def limits(self): + """ + These are the limits required. These are guaranteed to be satisfied. + :rtype: list[Resources.ResourceEntry] + """ + return self._limits + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.tasks_pb2.Resources + """ + return _core_task.Resources( + requests=[r.to_flyte_idl() for r in self.requests], + limits=[r.to_flyte_idl() for r in self.limits], + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.tasks_pb2.Resources.ResourceEntry pb2_object: + :rtype: Resources + """ + return cls( + requests=[Resources.ResourceEntry.from_flyte_idl(r) for r in pb2_object.requests], + limits=[Resources.ResourceEntry.from_flyte_idl(l) for l in pb2_object.limits], + ) + + +class RuntimeMetadata(_common.FlyteIdlEntity): + class RuntimeType(object): + OTHER = 0 + FLYTE_SDK = 1 + + def __init__(self, type, version, flavor): + """ + :param int type: Enum type from RuntimeMetadata.RuntimeType + :param Text version: Version string for SDK version. Can be used for metrics or managing breaking changes in + Admin or Propeller + :param Text flavor: Optional extra information about runtime environment (e.g. Python, GoLang, etc.) + """ + self._type = type + self._version = version + self._flavor = flavor + + @property + def type(self): + """ + Enum type from RuntimeMetadata.RuntimeType + :rtype: int + """ + return self._type + + @property + def version(self): + """ + Version string for SDK version. Can be used for metrics or managing breaking changes in Admin or Propeller + :rtype: Text + """ + return self._version + + @property + def flavor(self): + """ + Optional extra information about runtime environment (e.g. Python, GoLang, etc.) + :rtype: Text + """ + return self._flavor + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.tasks_pb2.RuntimeMetadata + """ + return _core_task.RuntimeMetadata(type=self.type, version=self.version, flavor=self.flavor) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.tasks_pb2.RuntimeMetadata pb2_object: + :rtype: RuntimeMetadata + """ + return cls(type=pb2_object.type, version=pb2_object.version, flavor=pb2_object.flavor) + + +class TaskMetadata(_common.FlyteIdlEntity): + def __init__( + self, + discoverable, + runtime, + timeout, + retries, + interruptible, + discovery_version, + deprecated_error_message, + cache_serializable, + pod_template_name, + ): + """ + Information needed at runtime to determine behavior such as whether or not outputs are discoverable, timeouts, + and retries. + + :param bool discoverable: Whether or not the outputs of this task should be cached for discovery. + :param RuntimeMetadata runtime: Metadata describing the runtime environment for this task. + :param datetime.timedelta timeout: The amount of time to wait before timing out. This includes queuing and + scheduler latency. + :param bool interruptible: Whether or not the task is interruptible. + :param flytekit.models.literals.RetryStrategy retries: Retry strategy for this task. 0 retries means only + try once. + :param Text discovery_version: This is the version used to create a logical version for data in the cache. + This is only used when `discoverable` is true. Data is considered discoverable if: the inputs to a given + task are the same and the discovery_version is also the same. + :param Text deprecated: This string can be used to mark the task as deprecated. Consumers of the task will + receive deprecation warnings. + :param bool cache_serializable: Whether or not caching operations are executed in serial. This means only a + single instance over identical inputs is executed, other concurrent executions wait for the cached results. + :param pod_template_name: The name of the existing PodTemplate resource which will be used in this task. + """ + self._discoverable = discoverable + self._runtime = runtime + self._timeout = timeout + self._interruptible = interruptible + self._retries = retries + self._discovery_version = discovery_version + self._deprecated_error_message = deprecated_error_message + self._cache_serializable = cache_serializable + self._pod_template_name = pod_template_name + + @property + def discoverable(self): + """ + Whether or not the outputs of this task should be cached for discovery. + :rtype: bool + """ + return self._discoverable + + @property + def runtime(self): + """ + Metadata describing the runtime environment for this task. + :rtype: RuntimeMetadata + """ + return self._runtime + + @property + def retries(self): + """ + Retry strategy for this task. 0 retries means only try once. + :rtype: flytekit.models.literals.RetryStrategy + """ + return self._retries + + @property + def timeout(self): + """ + The amount of time to wait before timing out. This includes queuing and scheduler latency. + :rtype: datetime.timedelta + """ + return self._timeout + + @property + def interruptible(self): + """ + Whether or not the task is interruptible. + :rtype: bool + """ + return self._interruptible + + @property + def discovery_version(self): + """ + This is the version used to create a logical version for data in the cache. + This is only used when `discoverable` is true. Data is considered discoverable if: the inputs to a given + task are the same and the discovery_version is also the same. + :rtype: Text + """ + return self._discovery_version + + @property + def deprecated_error_message(self): + """ + This string can be used to mark the task as deprecated. Consumers of the task will receive deprecation + warnings. + :rtype: Text + """ + return self._deprecated_error_message + + @property + def cache_serializable(self): + """ + Whether or not caching operations are executed in serial. This means only a single instance over identical + inputs is executed, other concurrent executions wait for the cached results. + :rtype: bool + """ + return self._cache_serializable + + @property + def pod_template_name(self): + """ + The name of the existing PodTemplate resource which will be used in this task. + :rtype: Text + """ + return self._pod_template_name + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.task_pb2.TaskMetadata + """ + tm = _core_task.TaskMetadata( + discoverable=self.discoverable, + runtime=self.runtime.to_flyte_idl(), + retries=self.retries.to_flyte_idl(), + interruptible=self.interruptible, + discovery_version=self.discovery_version, + deprecated_error_message=self.deprecated_error_message, + cache_serializable=self.cache_serializable, + pod_template_name=self.pod_template_name, + ) + if self.timeout: + tm.timeout.FromTimedelta(self.timeout) + return tm + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.task_pb2.TaskMetadata pb2_object: + :rtype: TaskMetadata + """ + return cls( + discoverable=pb2_object.discoverable, + runtime=RuntimeMetadata.from_flyte_idl(pb2_object.runtime), + timeout=pb2_object.timeout.ToTimedelta(), + interruptible=pb2_object.interruptible if pb2_object.HasField("interruptible") else None, + retries=_literals.RetryStrategy.from_flyte_idl(pb2_object.retries), + discovery_version=pb2_object.discovery_version, + deprecated_error_message=pb2_object.deprecated_error_message, + cache_serializable=pb2_object.cache_serializable, + pod_template_name=pb2_object.pod_template_name, + ) + + +class TaskTemplate(_common.FlyteIdlEntity): + def __init__( + self, + id, + type, + metadata, + interface, + custom, + container=None, + task_type_version=0, + security_context=None, + config=None, + k8s_pod=None, + sql=None, + extended_resources=None, + ): + """ + A task template represents the full set of information necessary to perform a unit of work in the Flyte system. + It contains the metadata about what inputs and outputs are consumed or produced. It also contains the metadata + necessary for Flyte Propeller to do the appropriate work. + + :param flytekit.models.core.identifier.Identifier id: This is generated by the system and uniquely identifies + the task. + :param Text type: This is used to define additional extensions for use by Propeller or SDK. + :param TaskMetadata metadata: This contains information needed at runtime to determine behavior such as + whether or not outputs are discoverable, timeouts, and retries. + :param flytekit.models.interface.TypedInterface interface: The interface definition for this task. + :param dict[Text, T] custom: Dictionary that must be serializable to a protobuf Struct for custom task plugins. + :param Container container: Provides the necessary entrypoint information for execution. For instance, + a Container might be specified with the necessary command line arguments. + :param int task_type_version: Specific version of this task type used by plugins to potentially modify + execution behavior or serialization. + :param dict[str, str] config: For plugin tasks this represents additional configuration information to be used + in tandem with the custom. + :param dict[str, str] config: For plugin tasks this represents additional configuration information to be used + in tandem with the custom. + :param K8sPod k8s_pod: Alternative to the container used to execute this task. + :param Sql sql: This is used to execute query in FlytePropeller instead of running container or k8s_pod. + :param flyteidl.core.tasks_pb2.ExtendedResources extended_resources: The extended resources to allocate to the task. + """ + if ( + (container is not None and k8s_pod is not None) + or (container is not None and sql is not None) + or (k8s_pod is not None and sql is not None) + ): + raise ValueError("At most one of container, k8s_pod or sql can be set") + self._id = id + self._type = type + self._metadata = metadata + self._interface = interface + self._custom = custom + self._container = container + self._task_type_version = task_type_version + self._config = config + self._security_context = security_context + self._k8s_pod = k8s_pod + self._sql = sql + self._extended_resources = extended_resources + + @property + def id(self): + """ + This is generated by the system and uniquely identifies the task. + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._id + + @property + def type(self): + """ + This is used to identify additional extensions for use by Propeller or SDK. + :rtype: Text + """ + return self._type + + @property + def metadata(self): + """ + This contains information needed at runtime to determine behavior such as whether or not outputs are + discoverable, timeouts, and retries. + :rtype: TaskMetadata + """ + return self._metadata + + @property + def interface(self): + """ + The interface definition for this task. + :rtype: flytekit.models.interface.TypedInterface + """ + return self._interface + + @property + def custom(self): + """ + Arbitrary dictionary containing metadata for custom plugins. + :rtype: dict[Text, T] + """ + return self._custom + + @property + def task_type_version(self): + return self._task_type_version + + @property + def container(self): + """ + If not None, the target of execution should be a container. + :rtype: Container + """ + return self._container + + @property + def config(self): + """ + Arbitrary dictionary containing metadata for parsing and handling custom plugins. + :rtype: dict[Text, T] + """ + return self._config + + @property + def security_context(self): + return self._security_context + + @property + def k8s_pod(self): + return self._k8s_pod + + @property + def sql(self): + return self._sql + + @property + def extended_resources(self): + """ + If not None, the extended resources to allocate to the task. + :rtype: flyteidl.core.tasks_pb2.ExtendedResources + """ + return self._extended_resources + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.tasks_pb2.TaskTemplate + """ + task_template = _core_task.TaskTemplate( + id=self.id.to_flyte_idl(), + type=self.type, + metadata=self.metadata.to_flyte_idl(), + interface=self.interface.to_flyte_idl(), + custom=_json_format.Parse(_json.dumps(self.custom), _struct.Struct()) if self.custom else None, + container=self.container.to_flyte_idl() if self.container else None, + task_type_version=self.task_type_version, + security_context=self.security_context.to_flyte_idl() if self.security_context else None, + extended_resources=self.extended_resources, + config={k: v for k, v in self.config.items()} if self.config is not None else None, + k8s_pod=self.k8s_pod.to_flyte_idl() if self.k8s_pod else None, + sql=self.sql.to_flyte_idl() if self.sql else None, + ) + return task_template + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.tasks_pb2.TaskTemplate pb2_object: + :rtype: TaskTemplate + """ + return cls( + id=_identifier.Identifier.from_flyte_idl(pb2_object.id), + type=pb2_object.type, + metadata=TaskMetadata.from_flyte_idl(pb2_object.metadata), + interface=_interface.TypedInterface.from_flyte_idl(pb2_object.interface), + custom=_json_format.MessageToDict(pb2_object.custom) if pb2_object else None, + container=Container.from_flyte_idl(pb2_object.container) if pb2_object.HasField("container") else None, + task_type_version=pb2_object.task_type_version, + security_context=_sec.SecurityContext.from_flyte_idl(pb2_object.security_context) + if pb2_object.security_context and pb2_object.security_context.ByteSize() > 0 + else None, + extended_resources=pb2_object.extended_resources if pb2_object.HasField("extended_resources") else None, + config={k: v for k, v in pb2_object.config.items()} if pb2_object.config is not None else None, + k8s_pod=K8sPod.from_flyte_idl(pb2_object.k8s_pod) if pb2_object.HasField("k8s_pod") else None, + sql=Sql.from_flyte_idl(pb2_object.sql) if pb2_object.HasField("sql") else None, + ) + + +class TaskSpec(_common.FlyteIdlEntity): + def __init__(self, template: TaskTemplate, docs: typing.Optional[Documentation] = None): + """ + :param TaskTemplate template: + :param Documentation docs: + """ + self._template = template + self._docs = docs + + @property + def template(self): + """ + :rtype: TaskTemplate + """ + return self._template + + @property + def docs(self): + """ + :rtype: Description entity for the task + """ + return self._docs + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.tasks_pb2.TaskSpec + """ + return _admin_task.TaskSpec( + template=self.template.to_flyte_idl(), description=self.docs.to_flyte_idl() if self.docs else None + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.tasks_pb2.TaskSpec pb2_object: + :rtype: TaskSpec + """ + return cls( + TaskTemplate.from_flyte_idl(pb2_object.template), + Documentation.from_flyte_idl(pb2_object.description) if pb2_object.description else None, + ) + + +class Task(_common.FlyteIdlEntity): + def __init__(self, id, closure): + """ + :param flytekit.models.core.identifier.Identifier id: The (project, domain, name) identifier for this task. + :param TaskClosure closure: The closure for the underlying workload. + """ + self._id = id + self._closure = closure + + @property + def id(self): + """ + The (project, domain, name, version) identifier for this task. + :rtype: flytekit.models.core.identifier.Identifier + """ + return self._id + + @property + def closure(self): + """ + The closure for the underlying workload. + :rtype: TaskClosure + """ + return self._closure + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.task_pb2.Task + """ + return _admin_task.Task( + closure=self.closure.to_flyte_idl(), + id=self.id.to_flyte_idl(), + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.task_pb2.Task pb2_object: + :rtype: TaskDefinition + """ + return cls( + closure=TaskClosure.from_flyte_idl(pb2_object.closure), + id=_identifier.Identifier.from_flyte_idl(pb2_object.id), + ) + + +class TaskClosure(_common.FlyteIdlEntity): + def __init__(self, compiled_task): + """ + :param CompiledTask compiled_task: + """ + self._compiled_task = compiled_task + + @property + def compiled_task(self): + """ + :rtype: CompiledTask + """ + return self._compiled_task + + def to_flyte_idl(self): + """ + :rtype: flyteidl.admin.task_pb2.TaskClosure + """ + return _admin_task.TaskClosure(compiled_task=self.compiled_task.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.task_pb2.TaskClosure pb2_object: + :rtype: TaskClosure + """ + return cls(compiled_task=CompiledTask.from_flyte_idl(pb2_object.compiled_task)) + + +class CompiledTask(_common.FlyteIdlEntity): + def __init__(self, template): + """ + :param TaskTemplate template: + """ + self._template = template + + @property + def template(self): + """ + :rtype: TaskTemplate + """ + return self._template + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.compiler_pb2.CompiledTask + """ + return _compiler.CompiledTask(template=self.template.to_flyte_idl()) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.compiler_pb2.CompiledTask pb2_object: + :rtype: CompiledTask + """ + return cls(template=TaskTemplate.from_flyte_idl(pb2_object.template)) + + +class IOStrategy(_common.FlyteIdlEntity): + """ + Provides methods to manage data in and out of the Raw container using Download Modes. This can only be used if DataLoadingConfig is enabled. + """ + + DOWNLOAD_MODE_EAGER = _core_task.IOStrategy.DOWNLOAD_EAGER + DOWNLOAD_MODE_STREAM = _core_task.IOStrategy.DOWNLOAD_STREAM + DOWNLOAD_MODE_NO_DOWNLOAD = _core_task.IOStrategy.DO_NOT_DOWNLOAD + + UPLOAD_MODE_EAGER = _core_task.IOStrategy.UPLOAD_EAGER + UPLOAD_MODE_ON_EXIT = _core_task.IOStrategy.UPLOAD_ON_EXIT + UPLOAD_MODE_NO_UPLOAD = _core_task.IOStrategy.DO_NOT_UPLOAD + + def __init__( + self, + download_mode: _core_task.IOStrategy.DownloadMode = DOWNLOAD_MODE_EAGER, + upload_mode: _core_task.IOStrategy.UploadMode = UPLOAD_MODE_ON_EXIT, + ): + self._download_mode = download_mode + self._upload_mode = upload_mode + + def to_flyte_idl(self) -> _core_task.IOStrategy: + return _core_task.IOStrategy(download_mode=self._download_mode, upload_mode=self._upload_mode) + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_task.IOStrategy): + if pb2_object is None: + return None + return cls( + download_mode=pb2_object.download_mode, + upload_mode=pb2_object.upload_mode, + ) + + +class DataLoadingConfig(_common.FlyteIdlEntity): + LITERALMAP_FORMAT_PROTO = _core_task.DataLoadingConfig.PROTO + LITERALMAP_FORMAT_JSON = _core_task.DataLoadingConfig.JSON + LITERALMAP_FORMAT_YAML = _core_task.DataLoadingConfig.YAML + _LITERALMAP_FORMATS = frozenset([LITERALMAP_FORMAT_JSON, LITERALMAP_FORMAT_PROTO, LITERALMAP_FORMAT_YAML]) + + def __init__( + self, + input_path: str, + output_path: str, + enabled: bool = True, + format: _core_task.DataLoadingConfig.LiteralMapFormat = LITERALMAP_FORMAT_PROTO, + io_strategy: IOStrategy = None, + ): + if format not in self._LITERALMAP_FORMATS: + raise ValueError( + "Metadata format {} not supported. Should be one of {}".format(format, self._LITERALMAP_FORMATS) + ) + self._input_path = input_path + self._output_path = output_path + self._enabled = enabled + self._format = format + self._io_strategy = io_strategy + + def to_flyte_idl(self) -> _core_task.DataLoadingConfig: + return _core_task.DataLoadingConfig( + input_path=self._input_path, + output_path=self._output_path, + format=self._format, + enabled=self._enabled, + io_strategy=self._io_strategy.to_flyte_idl() if self._io_strategy is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2: _core_task.DataLoadingConfig) -> "DataLoadingConfig": + if pb2 is None: + return None + return cls( + input_path=pb2.input_path, + output_path=pb2.output_path, + enabled=pb2.enabled, + format=pb2.format, + io_strategy=IOStrategy.from_flyte_idl(pb2.io_strategy) if pb2.HasField("io_strategy") else None, + ) + + +class Container(_common.FlyteIdlEntity): + def __init__(self, image, command, args, resources, env, config, data_loading_config=None): + """ + This defines a container target. It will execute the appropriate command line on the appropriate image with + the given configurations. + + :param Text image: The fully-qualified identifier for the image. + :param list[Text] command: A list of 'words' for the command. i.e. ['aws', 's3', 'ls'] + :param list[Text] args: A list of arguments for the command. i.e. ['s3://some/path', '/tmp/local/path'] + :param Resources resources: A definition of requisite compute resources. + :param dict[Text, Text] env: A definition of key-value pairs for environment variables. + :param dict[Text, Text] config: A definition of configuration key-value pairs. + :type DataLoadingConfig data_loading_config: object + """ + self._data_loading_config = data_loading_config + self._image = image + self._command = command + self._args = args + self._resources = resources + self._env = env + self._config = config + + @property + def image(self): + """ + The fully-qualified identifier for the image. + :rtype: Text + """ + return self._image + + @property + def command(self): + """ + A list of 'words' for the command. i.e. ['aws', 's3', 'ls'] + :rtype: list[Text] + """ + return self._command + + @property + def args(self): + """ + A list of arguments for the command. i.e. ['s3://some/path', '/tmp/local/path'] + :rtype: list[Text] + """ + return self._args + + @property + def resources(self): + """ + A definition of requisite compute resources. + :rtype: Resources + """ + return self._resources + + @property + def env(self): + """ + A definition of key-value pairs for environment variables. Currently, only str->str is + supported. + :rtype: dict[Text, Text] + """ + return self._env + + def add_env(self, key: str, val: str): + self._env[key] = val + + @property + def config(self): + """ + A definition of key-value pairs for configuration. Currently, only str->str is + supported. + :rtype: dict[Text, Text] + """ + return self._config + + @property + def data_loading_config(self): + """ + :rtype: DataLoadingConfig + """ + return self._data_loading_config + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.tasks_pb2.Container + """ + return _core_task.Container( + image=self.image, + command=self.command, + args=self.args, + resources=self.resources.to_flyte_idl(), + env=[_literals_pb2.KeyValuePair(key=k, value=v) for k, v in self.env.items()], + config=[_literals_pb2.KeyValuePair(key=k, value=v) for k, v in self.config.items()], + data_config=self._data_loading_config.to_flyte_idl() if self._data_loading_config else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.admin.task_pb2.Task pb2_object: + :rtype: Container + """ + return cls( + image=pb2_object.image, + command=pb2_object.command, + args=pb2_object.args, + resources=Resources.from_flyte_idl(pb2_object.resources), + env={kv.key: kv.value for kv in pb2_object.env}, + config={kv.key: kv.value for kv in pb2_object.config}, + data_loading_config=DataLoadingConfig.from_flyte_idl(pb2_object.data_config) + if pb2_object.HasField("data_config") + else None, + ) + + +class K8sObjectMetadata(_common.FlyteIdlEntity): + def __init__(self, labels: typing.Dict[str, str] = None, annotations: typing.Dict[str, str] = None): + """ + This defines additional metadata for building a kubernetes pod. + """ + self._labels = labels + self._annotations = annotations + + @property + def labels(self) -> typing.Dict[str, str]: + return self._labels + + @property + def annotations(self) -> typing.Dict[str, str]: + return self._annotations + + def to_flyte_idl(self) -> _core_task.K8sObjectMetadata: + return _core_task.K8sObjectMetadata( + labels={k: v for k, v in self.labels.items()} if self.labels is not None else None, + annotations={k: v for k, v in self.annotations.items()} if self.annotations is not None else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_task.K8sObjectMetadata): + return cls( + labels={k: v for k, v in pb2_object.labels.items()} if pb2_object.labels is not None else None, + annotations={k: v for k, v in pb2_object.annotations.items()} + if pb2_object.annotations is not None + else None, + ) + + +class K8sPod(_common.FlyteIdlEntity): + 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: + return self._metadata + + @property + 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 + 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, + ) + + +class Sql(_common.FlyteIdlEntity): + class Dialect(object): + ANSI = 0 + HIVE = 1 + + def __init__(self, statement: str = None, dialect: int = 0): + """ + This defines a kubernetes pod target. It will build the pod target during task execution + """ + self._statement = statement + self._dialect = dialect + + @property + def statement(self) -> str: + return self._statement + + @property + def dialect(self) -> int: + return self._dialect + + def to_flyte_idl(self) -> _core_task.Sql: + return _core_task.Sql(statement=self.statement, dialect=self.dialect) + + @classmethod + def from_flyte_idl(cls, pb2_object: _core_task.Sql): + return cls( + statement=pb2_object.statement, + dialect=pb2_object.dialect, + ) diff --git a/flytekit/flytekit/models/types.py b/flytekit/flytekit/models/types.py new file mode 100644 index 0000000000..60ca8b84a4 --- /dev/null +++ b/flytekit/flytekit/models/types.py @@ -0,0 +1,508 @@ +import json as _json +import typing +from typing import Dict + +from flyteidl.core import types_pb2 as _types_pb2 +from google.protobuf import json_format as _json_format +from google.protobuf import struct_pb2 as _struct + +from flytekit.models import common as _common +from flytekit.models.annotation import TypeAnnotation as TypeAnnotationModel +from flytekit.models.core import types as _core_types + + +class SimpleType(object): + NONE = _types_pb2.NONE + INTEGER = _types_pb2.INTEGER + FLOAT = _types_pb2.FLOAT + STRING = _types_pb2.STRING + BOOLEAN = _types_pb2.BOOLEAN + DATETIME = _types_pb2.DATETIME + DURATION = _types_pb2.DURATION + BINARY = _types_pb2.BINARY + ERROR = _types_pb2.ERROR + STRUCT = _types_pb2.STRUCT + + +class SchemaType(_common.FlyteIdlEntity): + class SchemaColumn(_common.FlyteIdlEntity): + class SchemaColumnType(object): + INTEGER = _types_pb2.SchemaType.SchemaColumn.INTEGER + FLOAT = _types_pb2.SchemaType.SchemaColumn.FLOAT + STRING = _types_pb2.SchemaType.SchemaColumn.STRING + DATETIME = _types_pb2.SchemaType.SchemaColumn.DATETIME + DURATION = _types_pb2.SchemaType.SchemaColumn.DURATION + BOOLEAN = _types_pb2.SchemaType.SchemaColumn.BOOLEAN + + def __init__(self, name, type): + """ + :param Text name: Name for the column + :param int type: Enum type from SchemaType.SchemaColumn.SchemaColumnType representing the type of the column + """ + self._name = name + self._type = type + + @property + def name(self): + """ + Name for the column + :rtype: Text + """ + return self._name + + @property + def type(self): + """ + Enum type from SchemaType.SchemaColumn.SchemaColumnType representing the type of the column + :rtype: int + """ + return self._type + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.types_pb2.SchemaType.SchemaColumn + """ + return _types_pb2.SchemaType.SchemaColumn(name=self.name, type=self.type) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.types_pb2.SchemaType.SchemaColumn proto: + :rtype: SchemaType.SchemaColumn + """ + return cls(name=proto.name, type=proto.type) + + def __init__(self, columns): + """ + :param list[SchemaType.SchemaColumn] columns: A list of columns defining the underlying data frame. + """ + self._columns = columns + + @property + def columns(self): + """ + A list of columns defining the underlying data frame. + :rtype: list[SchemaType.SchemaColumn] + """ + return self._columns + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.types_pb2.SchemaType + """ + return _types_pb2.SchemaType(columns=[c.to_flyte_idl() for c in self.columns]) + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.types_pb2.SchemaType proto: + :rtype: SchemaType + """ + return cls(columns=[SchemaType.SchemaColumn.from_flyte_idl(c) for c in proto.columns]) + + +class UnionType(_common.FlyteIdlEntity): + """ + Models _types_pb2.UnionType + """ + + def __init__(self, variants: typing.List["LiteralType"]): + self._variants = variants + + @property + def variants(self) -> typing.List["LiteralType"]: + return self._variants + + def to_flyte_idl(self) -> _types_pb2.UnionType: + return _types_pb2.UnionType( + variants=[val.to_flyte_idl() if val else None for val in self._variants], + ) + + @classmethod + def from_flyte_idl(cls, proto: _types_pb2.UnionType): + return cls(variants=[LiteralType.from_flyte_idl(v) for v in proto.variants]) + + +class TypeStructure(_common.FlyteIdlEntity): + """ + Models _types_pb2.TypeStructure + """ + + def __init__(self, tag: str, dataclass_type: Dict[str, "LiteralType"] = None): + self._tag = tag + self._dataclass_type = dataclass_type + + @property + def tag(self) -> str: + return self._tag + + @property + def dataclass_type(self) -> Dict[str, "LiteralType"]: + return self._dataclass_type + + def to_flyte_idl(self) -> _types_pb2.TypeStructure: + return _types_pb2.TypeStructure( + tag=self._tag, + dataclass_type={k: v.to_flyte_idl() for k, v in self._dataclass_type.items()} + if self._dataclass_type is not None + else None, + ) + + @classmethod + def from_flyte_idl(cls, proto: _types_pb2.TypeStructure): + return cls( + tag=proto.tag, + dataclass_type={k: LiteralType.from_flyte_idl(v) for k, v in proto.dataclass_type.items()} + if proto.dataclass_type is not None + else None, + ) + + +class StructuredDatasetType(_common.FlyteIdlEntity): + class DatasetColumn(_common.FlyteIdlEntity): + def __init__(self, name: str, literal_type: "LiteralType"): + self._name = name + self._literal_type = literal_type + + @property + def name(self) -> str: + """ + Name for the column + """ + return self._name + + @property + def literal_type(self) -> "LiteralType": + """ + A LiteralType that defines the type of this column + """ + return self._literal_type + + def to_flyte_idl(self) -> _types_pb2.StructuredDatasetType.DatasetColumn: + return _types_pb2.StructuredDatasetType.DatasetColumn( + name=self.name, literal_type=self.literal_type.to_flyte_idl() + ) + + @classmethod + def from_flyte_idl( + cls, proto: _types_pb2.StructuredDatasetType.DatasetColumn + ) -> _types_pb2.StructuredDatasetType.DatasetColumn: + return cls(name=proto.name, literal_type=LiteralType.from_flyte_idl(proto.literal_type)) + + def __init__( + self, + columns: typing.List[DatasetColumn] = None, + format: str = "", + external_schema_type: str = None, + external_schema_bytes: bytes = None, + ): + self._columns = columns + self._format = format + self._external_schema_type = external_schema_type + self._external_schema_bytes = external_schema_bytes + + @property + def columns(self) -> typing.List[DatasetColumn]: + return self._columns + + @columns.setter + def columns(self, value): + self._columns = value + + @property + def format(self) -> str: + return self._format + + @format.setter + def format(self, format: str): + self._format = format + + @property + def external_schema_type(self) -> str: + return self._external_schema_type + + @property + def external_schema_bytes(self) -> bytes: + return self._external_schema_bytes + + def to_flyte_idl(self) -> _types_pb2.StructuredDatasetType: + return _types_pb2.StructuredDatasetType( + columns=[c.to_flyte_idl() for c in self.columns] if self.columns else None, + format=self.format, + external_schema_type=self.external_schema_type if self.external_schema_type else None, + external_schema_bytes=self.external_schema_bytes if self.external_schema_bytes else None, + ) + + @classmethod + def from_flyte_idl(cls, proto: _types_pb2.StructuredDatasetType) -> _types_pb2.StructuredDatasetType: + return cls( + columns=[StructuredDatasetType.DatasetColumn.from_flyte_idl(c) for c in proto.columns], + format=proto.format, + external_schema_type=proto.external_schema_type, + external_schema_bytes=proto.external_schema_bytes, + ) + + +class LiteralType(_common.FlyteIdlEntity): + def __init__( + self, + simple=None, + schema=None, + collection_type=None, + map_value_type=None, + blob=None, + enum_type=None, + union_type=None, + structured_dataset_type=None, + metadata=None, + structure=None, + annotation=None, + ): + """ + This is a oneof message, only one of the kwargs may be set, representing one of the Flyte types. + + :param SimpleType simple: Enum type from SimpleType + :param SchemaType schema: Type definition for a dataframe-like object. + :param LiteralType collection_type: For list-like objects, this is the type of each entry in the list. + :param LiteralType map_value_type: For map objects, this is the type of the value. The key must always be a + string. + :param flytekit.models.core.types.BlobType blob: For blob objects, this describes the type. + :param flytekit.models.core.types.EnumType enum_type: For enum objects, describes an enum + :param flytekit.models.core.types.UnionType union_type: For union objects, describes an python union type. + :param flytekit.models.core.types.TypeStructure structure: Type matching hints + :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 + """ + self._simple = simple + self._schema = schema + self._collection_type = collection_type + self._map_value_type = map_value_type + self._blob = blob + self._enum_type = enum_type + self._union_type = union_type + self._structured_dataset_type = structured_dataset_type + self._metadata = metadata + self._structure = structure + self._structured_dataset_type = structured_dataset_type + self._metadata = metadata + self._annotation = annotation + + @property + def simple(self) -> SimpleType: + return self._simple + + @property + def schema(self) -> SchemaType: + return self._schema + + @property + def collection_type(self) -> "LiteralType": + """ + The collection value type + """ + return self._collection_type + + @property + def map_value_type(self) -> "LiteralType": + """ + The Value for a dictionary. Key is always string + """ + return self._map_value_type + + @property + def blob(self) -> _core_types.BlobType: + return self._blob + + @property + def enum_type(self) -> _core_types.EnumType: + return self._enum_type + + @property + def union_type(self) -> UnionType: + return self._union_type + + @property + def structure(self) -> TypeStructure: + return self._structure + + @property + def structured_dataset_type(self) -> StructuredDatasetType: + return self._structured_dataset_type + + @property + def metadata(self): + """ + :rtype: dict[Text, T] + """ + return self._metadata + + @property + def annotation(self) -> TypeAnnotationModel: + """ + :rtype: flytekit.models.annotation.TypeAnnotation + """ + return self._annotation + + @metadata.setter + def metadata(self, value): + self._metadata = value + + @annotation.setter + def annotation(self, value): + self.annotation = value + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.types_pb2.LiteralType + """ + + if self.metadata is not None: + metadata = _json_format.Parse(_json.dumps(self.metadata), _struct.Struct()) + else: + metadata = None + + t = _types_pb2.LiteralType( + simple=self.simple if self.simple is not None else None, + schema=self.schema.to_flyte_idl() if self.schema is not None else None, + collection_type=self.collection_type.to_flyte_idl() if self.collection_type is not None else None, + map_value_type=self.map_value_type.to_flyte_idl() if self.map_value_type is not None else None, + blob=self.blob.to_flyte_idl() if self.blob is not None else None, + enum_type=self.enum_type.to_flyte_idl() if self.enum_type else None, + union_type=self.union_type.to_flyte_idl() if self.union_type else None, + structured_dataset_type=self.structured_dataset_type.to_flyte_idl() + if self.structured_dataset_type + else None, + metadata=metadata, + annotation=self.annotation.to_flyte_idl() if self.annotation else None, + structure=self.structure.to_flyte_idl() if self.structure else None, + ) + return t + + @classmethod + def from_flyte_idl(cls, proto): + """ + :param flyteidl.core.types_pb2.LiteralType proto: + :rtype: LiteralType + """ + collection_type = None + map_value_type = None + if proto.HasField("collection_type"): + collection_type = LiteralType.from_flyte_idl(proto.collection_type) + if proto.HasField("map_value_type"): + map_value_type = LiteralType.from_flyte_idl(proto.map_value_type) + return cls( + simple=proto.simple if proto.HasField("simple") else None, + schema=SchemaType.from_flyte_idl(proto.schema) if proto.HasField("schema") else None, + collection_type=collection_type, + map_value_type=map_value_type, + blob=_core_types.BlobType.from_flyte_idl(proto.blob) if proto.HasField("blob") else None, + enum_type=_core_types.EnumType.from_flyte_idl(proto.enum_type) if proto.HasField("enum_type") else None, + union_type=UnionType.from_flyte_idl(proto.union_type) if proto.HasField("union_type") else None, + structured_dataset_type=StructuredDatasetType.from_flyte_idl(proto.structured_dataset_type) + if proto.HasField("structured_dataset_type") + else None, + metadata=_json_format.MessageToDict(proto.metadata) or None, + structure=TypeStructure.from_flyte_idl(proto.structure) if proto.HasField("structure") else None, + annotation=TypeAnnotationModel.from_flyte_idl(proto.annotation) if proto.HasField("annotation") else None, + ) + + +class OutputReference(_common.FlyteIdlEntity): + def __init__(self, node_id, var, attr_path: typing.List[typing.Union[str, int]] = None): + """ + A reference to an output produced by a node. The type can be retrieved -and validated- from + the underlying interface of the node. + + :param Text node_id: Node id must exist at the graph layer. + :param Text var: Variable name must refer to an output variable for the node. + :param List[Union[str, int]] attr_path: The attribute path the promise will be resolved with. + """ + self._node_id = node_id + self._var = var + self._attr_path = attr_path if attr_path is not None else [] + + @property + def node_id(self): + """ + Node id must exist at the graph layer. + :rtype: Text + """ + return self._node_id + + @property + def var(self): + """ + Variable name must refer to an output variable for the node. + :rtype: Text + """ + return self._var + + @property + def attr_path(self) -> typing.List[typing.Union[str, int]]: + """ + The attribute path the promise will be resolved with. + :rtype: list[union[str, int]] + """ + return self._attr_path + + @var.setter + def var(self, var_name): + self._var = var_name + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.types.OutputReference + """ + return _types_pb2.OutputReference( + node_id=self.node_id, + var=self.var, + attr_path=[ + _types_pb2.PromiseAttribute( + string_value=p if type(p) == str else None, + int_value=p if type(p) == int else None, + ) + for p in self._attr_path + ], + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.types.OutputReference pb2_object: + :rtype: OutputReference + """ + return cls( + node_id=pb2_object.node_id, + var=pb2_object.var, + attr_path=[p.string_value or p.int_value for p in pb2_object.attr_path], + ) + + +class Error(_common.FlyteIdlEntity): + def __init__(self, failed_node_id: str, message: str): + self._message = message + self._failed_node_id = failed_node_id + + @property + def message(self) -> str: + return self._message + + @property + def failed_node_id(self) -> str: + return self._failed_node_id + + def to_flyte_idl(self) -> _types_pb2.Error: + return _types_pb2.Error( + message=self._message, + failed_node_id=self._failed_node_id, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _types_pb2.Error) -> "Error": + """ + :param flyteidl.core.types.Error pb2_object: + :rtype: Error + """ + return cls(failed_node_id=pb2_object.failed_node_id, message=pb2_object.message) diff --git a/flytekit/flytekit/models/workflow_closure.py b/flytekit/flytekit/models/workflow_closure.py new file mode 100644 index 0000000000..fbf0b08688 --- /dev/null +++ b/flytekit/flytekit/models/workflow_closure.py @@ -0,0 +1,49 @@ +from flyteidl.core import workflow_closure_pb2 as _workflow_closure_pb2 + +from flytekit.models import common as _common +from flytekit.models import task as _task_models +from flytekit.models.core import workflow as _core_workflow_models + + +class WorkflowClosure(_common.FlyteIdlEntity): + def __init__(self, workflow, tasks=None): + """ + :param flytekit.models.core.workflow.WorkflowTemplate workflow: Workflow template + :param list[flytekit.models.task.TaskTemplate] tasks: [Optional] + """ + self._workflow = workflow + self._tasks = tasks + + @property + def workflow(self): + """ + :rtype: flytekit.models.core.workflow.WorkflowTemplate + """ + return self._workflow + + @property + def tasks(self): + """ + :rtype: list[flytekit.models.task.TaskTemplate] + """ + return self._tasks + + def to_flyte_idl(self): + """ + :rtype: flyteidl.core.workflow_closure_pb2.WorkflowClosure + """ + return _workflow_closure_pb2.WorkflowClosure( + workflow=self.workflow.to_flyte_idl(), + tasks=[t.to_flyte_idl() for t in self.tasks], + ) + + @classmethod + def from_flyte_idl(cls, pb2_object): + """ + :param flyteidl.core.workflow_closure_pb2.WorkflowClosure pb2_object + :rtype: WorkflowClosure + """ + return cls( + workflow=_core_workflow_models.WorkflowTemplate.from_flyte_idl(pb2_object.workflow), + tasks=[_task_models.TaskTemplate.from_flyte_idl(t) for t in pb2_object.tasks], + ) diff --git a/flytekit/flytekit/remote/__init__.py b/flytekit/flytekit/remote/__init__.py new file mode 100644 index 0000000000..dd92a813f2 --- /dev/null +++ b/flytekit/flytekit/remote/__init__.py @@ -0,0 +1,99 @@ +""" +===================== +Remote Access +===================== + +.. currentmodule:: flytekit.remote + +This module provides utilities for performing operations on tasks, workflows, launchplans, and executions, for example, +the following code fetches and executes a workflow: + +.. code-block:: python + + # create a remote object from flyte config and environment variables + FlyteRemote(config=Config.auto()) + FlyteRemote(config=Config.auto(config_file=....)) + FlyteRemote(config=Config(....)) + + # Or if you need to specify a custom cert chain + # (options and compression are also respected keyword arguments) + FlyteRemote(private_key=your_private_key_bytes, root_certificates=..., certificate_chain=...) + + # fetch a workflow from the flyte backend + remote = FlyteRemote(...) + flyte_workflow = remote.fetch_workflow(name="my_workflow", version="v1") + + # execute the workflow, wait=True will return the execution object after it's completed + workflow_execution = remote.execute(flyte_workflow, inputs={"a": 1, "b": 10}, wait=True) + + # inspect the execution's outputs + print(workflow_execution.outputs) + +.. _remote-entrypoint: + +Entrypoint +========== + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~remote.FlyteRemote + ~remote.Options + +.. _remote-flyte-entities: + +Entities +======== + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~entities.FlyteTask + ~entities.FlyteWorkflow + ~entities.FlyteLaunchPlan + +.. _remote-flyte-entity-components: + +Entity Components +================= + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~entities.FlyteNode + ~entities.FlyteTaskNode + ~entities.FlyteWorkflowNode + +.. _remote-flyte-execution-objects: + +Execution Objects +================= + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + :nosignatures: + + ~executions.FlyteWorkflowExecution + ~executions.FlyteTaskExecution + ~executions.FlyteNodeExecution + +""" + +from flytekit.remote.entities import ( + FlyteBranchNode, + FlyteLaunchPlan, + FlyteNode, + FlyteTask, + FlyteTaskNode, + FlyteWorkflow, + FlyteWorkflowNode, +) +from flytekit.remote.executions import FlyteNodeExecution, FlyteTaskExecution, FlyteWorkflowExecution +from flytekit.remote.remote import FlyteRemote diff --git a/flytekit/flytekit/remote/backfill.py b/flytekit/flytekit/remote/backfill.py new file mode 100644 index 0000000000..166f2e4745 --- /dev/null +++ b/flytekit/flytekit/remote/backfill.py @@ -0,0 +1,107 @@ +import logging +import typing +from datetime import datetime, timedelta + +from croniter import croniter + +from flytekit import LaunchPlan +from flytekit.core.workflow import ImperativeWorkflow, WorkflowBase, WorkflowFailurePolicy +from flytekit.remote.entities import FlyteLaunchPlan + + +def create_backfill_workflow( + start_date: datetime, + end_date: datetime, + for_lp: typing.Union[LaunchPlan, FlyteLaunchPlan], + parallel: bool = False, + per_node_timeout: timedelta = None, + per_node_retries: int = 0, + failure_policy: typing.Optional[WorkflowFailurePolicy] = None, +) -> typing.Tuple[WorkflowBase, datetime, datetime]: + """ + Generates a new imperative workflow for the launchplan that can be used to backfill the given launchplan. + This can only be used to generate backfilling workflow only for schedulable launchplans + + the Backfill plan is generated as (start_date - exclusive, end_date inclusive) + + .. code-block:: python + :caption: Correct usage for dates example + + lp = Launchplan.get_or_create(...) + start_date = datetime.datetime(2023, 1, 1) + end_date = start_date + datetime.timedelta(days=10) + wf = create_backfill_workflow(start_date, end_date, for_lp=lp) + + + .. code-block:: python + :caption: Incorrect date example + + wf = create_backfill_workflow(end_date, start_date, for_lp=lp) # end_date is before start_date + # OR + wf = create_backfill_workflow(start_date, start_date, for_lp=lp) # start and end date are same + + + :param start_date: datetime generate a backfill starting at this datetime (exclusive) + :param end_date: datetime generate a backfill ending at this datetime (inclusive) + :param for_lp: typing.Union[LaunchPlan, FlyteLaunchPlan] the backfill is generated for this launchplan + :param parallel: if the backfill should be run in parallel. False (default) will run each bacfill sequentially + :param per_node_timeout: timedelta Timeout to use per node + :param per_node_retries: int Retries to user per node + :param failure_policy: WorkflowFailurePolicy Failure policy to use for the backfill workflow + :return: WorkflowBase, datetime datetime -> New generated workflow, datetime for first instance of backfill, datetime for last instance of backfill + """ + if not for_lp: + raise ValueError("Launch plan is required!") + + if start_date >= end_date: + raise ValueError( + f"for a backfill start date should be earlier than end date. Received {start_date} -> {end_date}" + ) + + schedule = for_lp.entity_metadata.schedule if isinstance(for_lp, FlyteLaunchPlan) else for_lp.schedule + + if schedule is None: + raise ValueError("Backfill can only be created for scheduled launch plans") + + if schedule.cron_schedule is not None: + cron_schedule = schedule.cron_schedule + else: + raise NotImplementedError("Currently backfilling only supports cron schedules.") + + logging.info( + f"Generating backfill from {start_date} -> {end_date}. " + f"Parallel?[{parallel}] FailurePolicy[{str(failure_policy)}]" + ) + wf = ImperativeWorkflow(name=f"backfill-{for_lp.name}", failure_policy=failure_policy) + + 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 + actual_end = None + while True: + next_start_date = date_iter.get_next() + if not actual_start: + actual_start = next_start_date + if next_start_date >= end_date: + break + actual_end = 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 + ) + if not parallel: + if prev_node: + prev_node.runs_before(next_node) + prev_node = next_node + + if actual_end is None: + raise StopIteration( + f"The time window is too small for any backfill instances, first instance after start" + f" date is {actual_start}" + ) + + return wf, actual_start, actual_end diff --git a/flytekit/flytekit/remote/data.py b/flytekit/flytekit/remote/data.py new file mode 100644 index 0000000000..84fcff1420 --- /dev/null +++ b/flytekit/flytekit/remote/data.py @@ -0,0 +1,55 @@ +import os +import pathlib +import typing + +from google.protobuf.json_format import MessageToJson +from rich import print + +from flytekit import BlobType, Literal +from flytekit.core.data_persistence import FileAccessProvider +from flytekit.interaction.rich_utils import RichCallback +from flytekit.interaction.string_literals import literal_string_repr + + +def download_literal( + file_access: FileAccessProvider, var: str, data: Literal, download_to: typing.Optional[pathlib.Path] = None +): + """ + Download a single literal to a file, if it is a blob or structured dataset. + """ + if data is None: + print(f"Skipping {var} as it is None.") + return + if data.scalar: + if data.scalar and (data.scalar.blob or data.scalar.structured_dataset): + uri = data.scalar.blob.uri if data.scalar.blob else data.scalar.structured_dataset.uri + if uri is None: + print("No data to download.") + return + is_multipart = False + if data.scalar.blob: + is_multipart = data.scalar.blob.metadata.type.dimensionality == BlobType.BlobDimensionality.MULTIPART + elif data.scalar.structured_dataset: + is_multipart = True + file_access.get_data( + uri, str(download_to / var) + os.sep, is_multipart=is_multipart, callback=RichCallback() + ) + elif data.scalar.union is not None: + download_literal(file_access, var, data.scalar.union.value, download_to) + elif data.scalar.generic is not None: + with open(download_to / f"{var}.json", "w") as f: + f.write(MessageToJson(data.scalar.generic)) + else: + print( + f"[dim]Skipping {var} val {literal_string_repr(data)} as it is not a blob, structured dataset," + f" or generic type.[/dim]" + ) + return + elif data.collection: + for i, v in enumerate(data.collection.literals): + download_literal(file_access, f"{i}", v, download_to / var) + elif data.map: + download_to = pathlib.Path(download_to) + for k, v in data.map.literals.items(): + download_literal(file_access, f"{k}", v, download_to / var) + print(f"Downloaded f{var} to {download_to}") diff --git a/flytekit/flytekit/remote/entities.py b/flytekit/flytekit/remote/entities.py new file mode 100644 index 0000000000..2af0db3afb --- /dev/null +++ b/flytekit/flytekit/remote/entities.py @@ -0,0 +1,840 @@ +""" +This module contains shadow entities for all Flyte entities as represented in Flyte Admin / Control Plane. +The goal is to enable easy access, manipulation of these entities. +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple, Union + +from flytekit import FlyteContext +from flytekit.core import constants as _constants +from flytekit.core import hash as _hash_mixin +from flytekit.core import hash as hash_mixin +from flytekit.core.promise import create_and_link_node_from_remote +from flytekit.exceptions import system as _system_exceptions +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 launch_plan as _launch_plan_model +from flytekit.models import launch_plan as _launch_plan_models +from flytekit.models import launch_plan as launch_plan_models +from flytekit.models import task as _task_model +from flytekit.models import task as _task_models +from flytekit.models.admin.workflow import WorkflowSpec +from flytekit.models.core import compiler as compiler_models +from flytekit.models.core import identifier as _identifier_model +from flytekit.models.core import identifier as id_models +from flytekit.models.core import workflow as _workflow_model +from flytekit.models.core import workflow as _workflow_models +from flytekit.models.core.identifier import Identifier +from flytekit.models.core.workflow import Node, WorkflowMetadata, WorkflowMetadataDefaults +from flytekit.models.interface import TypedInterface +from flytekit.models.literals import Binding +from flytekit.models.task import TaskSpec +from flytekit.remote import interface as _interface +from flytekit.remote import interface as _interfaces +from flytekit.remote.remote_callable import RemoteEntity + + +class FlyteTask(hash_mixin.HashOnReferenceMixin, RemoteEntity, TaskSpec): + """A class encapsulating a remote Flyte task.""" + + def __init__( + self, + id, + type, + metadata, + interface, + custom, + container=None, + task_type_version: int = 0, + config=None, + should_register: bool = False, + ): + super(FlyteTask, self).__init__( + template=_task_model.TaskTemplate( + id, + type, + metadata, + interface, + custom, + container=container, + task_type_version=task_type_version, + config=config, + ) + ) + self._should_register = should_register + + @property + def id(self): + """ + This is generated by the system and uniquely identifies the task. + + :rtype: flytekit.models.core.identifier.Identifier + """ + return self.template.id + + @property + def type(self): + """ + This is used to identify additional extensions for use by Propeller or SDK. + + :rtype: Text + """ + return self.template.type + + @property + def metadata(self): + """ + This contains information needed at runtime to determine behavior such as whether or not outputs are + discoverable, timeouts, and retries. + + :rtype: TaskMetadata + """ + return self.template.metadata + + @property + def interface(self): + """ + The interface definition for this task. + + :rtype: flytekit.models.interface.TypedInterface + """ + return self.template.interface + + @property + def custom(self): + """ + Arbitrary dictionary containing metadata for custom plugins. + + :rtype: dict[Text, T] + """ + return self.template.custom + + @property + def task_type_version(self): + return self.template.task_type_version + + @property + def container(self): + """ + If not None, the target of execution should be a container. + + :rtype: Container + """ + return self.template.container + + @property + def config(self): + """ + Arbitrary dictionary containing metadata for parsing and handling custom plugins. + + :rtype: dict[Text, T] + """ + return self.template.config + + @property + def security_context(self): + return self.template.security_context + + @property + def k8s_pod(self): + return self.template.k8s_pod + + @property + def sql(self): + return self.template.sql + + @property + def should_register(self) -> bool: + return self._should_register + + @property + def name(self) -> str: + return self.template.id.name + + @property + def resource_type(self) -> _identifier_model.ResourceType: + return _identifier_model.ResourceType.TASK + + @property + def entity_type_text(self) -> str: + return "Task" + + @classmethod + def promote_from_model(cls, base_model: _task_model.TaskTemplate) -> FlyteTask: + t = cls( + id=base_model.id, + type=base_model.type, + metadata=base_model.metadata, + interface=_interfaces.TypedInterface.promote_from_model(base_model.interface), + custom=base_model.custom, + container=base_model.container, + task_type_version=base_model.task_type_version, + ) + # Override the newly generated name if one exists in the base model + if not base_model.id.is_empty: + t._id = base_model.id + + return t + + +class FlyteTaskNode(_workflow_model.TaskNode): + """A class encapsulating a task that a Flyte node needs to execute.""" + + def __init__(self, flyte_task: FlyteTask): + super(FlyteTaskNode, self).__init__(None) + self._flyte_task = flyte_task + + @property + def reference_id(self) -> id_models.Identifier: + """A globally unique identifier for the task.""" + return self._flyte_task.id + + @property + def flyte_task(self) -> FlyteTask: + return self._flyte_task + + @classmethod + def promote_from_model(cls, task: FlyteTask) -> FlyteTaskNode: + """ + Takes the idl wrapper for a TaskNode, + and returns the hydrated Flytekit object for it by fetching it with the FlyteTask control plane. + """ + return cls(flyte_task=task) + + +class FlyteWorkflowNode(_workflow_model.WorkflowNode): + """A class encapsulating a workflow that a Flyte node needs to execute.""" + + def __init__( + self, + flyte_workflow: FlyteWorkflow = None, + flyte_launch_plan: FlyteLaunchPlan = None, + ): + if flyte_workflow and flyte_launch_plan: + raise _system_exceptions.FlyteSystemException( + "FlyteWorkflowNode cannot be called with both a workflow and a launchplan specified, please pick " + f"one. workflow: {flyte_workflow} launchPlan: {flyte_launch_plan}", + ) + + self._flyte_workflow = flyte_workflow + self._flyte_launch_plan = flyte_launch_plan + super(FlyteWorkflowNode, self).__init__( + launchplan_ref=self._flyte_launch_plan.id if self._flyte_launch_plan else None, + sub_workflow_ref=self._flyte_workflow.id if self._flyte_workflow else None, + ) + + def __repr__(self) -> str: + if self.flyte_workflow is not None: + return f"FlyteWorkflowNode with workflow: {self.flyte_workflow}" + return f"FlyteWorkflowNode with launch plan: {self.flyte_launch_plan}" + + @property + def launchplan_ref(self) -> id_models.Identifier: + """A globally unique identifier for the launch plan, which should map to Admin.""" + return self._flyte_launch_plan.id if self._flyte_launch_plan else None + + @property + def sub_workflow_ref(self): + return self._flyte_workflow.id if self._flyte_workflow else None + + @property + def flyte_launch_plan(self) -> FlyteLaunchPlan: + return self._flyte_launch_plan + + @property + def flyte_workflow(self) -> FlyteWorkflow: + return self._flyte_workflow + + @classmethod + def _promote_workflow( + cls, + wf: _workflow_models.WorkflowTemplate, + sub_workflows: Optional[Dict[Identifier, _workflow_models.WorkflowTemplate]] = None, + tasks: Optional[Dict[Identifier, FlyteTask]] = None, + node_launch_plans: Optional[Dict[Identifier, launch_plan_models.LaunchPlanSpec]] = None, + ) -> FlyteWorkflow: + return FlyteWorkflow.promote_from_model( + wf, + sub_workflows=sub_workflows, + node_launch_plans=node_launch_plans, + tasks=tasks, + ) + + @classmethod + def promote_from_model( + cls, + base_model: _workflow_model.WorkflowNode, + sub_workflows: Dict[id_models.Identifier, _workflow_model.WorkflowTemplate], + node_launch_plans: Dict[id_models.Identifier, _launch_plan_model.LaunchPlanSpec], + tasks: Dict[Identifier, FlyteTask], + converted_sub_workflows: Dict[id_models.Identifier, FlyteWorkflow], + ) -> Tuple[FlyteWorkflowNode, Dict[id_models.Identifier, FlyteWorkflow]]: + if base_model.launchplan_ref is not None: + return ( + cls( + flyte_launch_plan=FlyteLaunchPlan.promote_from_model( + base_model.launchplan_ref, node_launch_plans[base_model.launchplan_ref] + ) + ), + converted_sub_workflows, + ) + elif base_model.sub_workflow_ref is not None: + # the workflow templates for sub-workflows should have been included in the original response + if base_model.reference in sub_workflows: + wf = None + if base_model.reference not in converted_sub_workflows: + wf = cls._promote_workflow( + sub_workflows[base_model.reference], + sub_workflows=sub_workflows, + node_launch_plans=node_launch_plans, + tasks=tasks, + ) + converted_sub_workflows[base_model.reference] = wf + else: + wf = converted_sub_workflows[base_model.reference] + return cls(flyte_workflow=wf), converted_sub_workflows + raise _system_exceptions.FlyteSystemException(f"Subworkflow {base_model.reference} not found.") + + raise _system_exceptions.FlyteSystemException( + "Bad workflow node model, neither subworkflow nor launchplan specified." + ) + + +class FlyteBranchNode(_workflow_model.BranchNode): + def __init__(self, if_else: _workflow_model.IfElseBlock): + super().__init__(if_else) + + @classmethod + def promote_from_model( + cls, + base_model: _workflow_model.BranchNode, + sub_workflows: Dict[id_models.Identifier, _workflow_model.WorkflowTemplate], + node_launch_plans: Dict[id_models.Identifier, _launch_plan_model.LaunchPlanSpec], + tasks: Dict[id_models.Identifier, FlyteTask], + converted_sub_workflows: Dict[id_models.Identifier, FlyteWorkflow], + ) -> Tuple[FlyteBranchNode, Dict[id_models.Identifier, FlyteWorkflow]]: + block = base_model.if_else + block.case._then_node, converted_sub_workflows = FlyteNode.promote_from_model( + block.case.then_node, + sub_workflows, + node_launch_plans, + tasks, + converted_sub_workflows, + ) + + for o in block.other: + o._then_node, converted_sub_workflows = FlyteNode.promote_from_model( + o.then_node, sub_workflows, node_launch_plans, tasks, converted_sub_workflows + ) + + else_node = None + if block.else_node: + else_node, converted_sub_workflows = FlyteNode.promote_from_model( + block.else_node, sub_workflows, node_launch_plans, tasks, converted_sub_workflows + ) + + new_if_else_block = _workflow_model.IfElseBlock(block.case, block.other, else_node, block.error) + + return cls(new_if_else_block), converted_sub_workflows + + +class FlyteGateNode(_workflow_model.GateNode): + @classmethod + def promote_from_model(cls, model: _workflow_model.GateNode): + return cls(model.signal, model.sleep, model.approve) + + +class FlyteArrayNode(_workflow_model.ArrayNode): + @classmethod + def promote_from_model(cls, model: _workflow_model.ArrayNode): + return cls(model._parallelism, model._node, model._min_success_ratio, model._min_successes) + + +class FlyteNode(_hash_mixin.HashOnReferenceMixin, _workflow_model.Node): + """A class encapsulating a remote Flyte node.""" + + def __init__( + self, + id, + upstream_nodes, + bindings, + metadata, + task_node: Optional[FlyteTaskNode] = None, + workflow_node: Optional[FlyteWorkflowNode] = None, + branch_node: Optional[FlyteBranchNode] = None, + gate_node: Optional[FlyteGateNode] = None, + array_node: Optional[FlyteArrayNode] = None, + ): + if not task_node and not workflow_node and not branch_node and not gate_node and not array_node: + raise _user_exceptions.FlyteAssertion( + "An Flyte node must have one of task|workflow|branch|gate|array entity specified at once" + ) + # TODO: Revisit flyte_branch_node and flyte_gate_node, should they be another type like Condition instead + # of a node? + self._flyte_task_node = task_node + if task_node: + self._flyte_entity = task_node.flyte_task + elif workflow_node: + self._flyte_entity = workflow_node.flyte_workflow or workflow_node.flyte_launch_plan + else: + self._flyte_entity = branch_node or gate_node or array_node + + super(FlyteNode, self).__init__( + id=id, + metadata=metadata, + inputs=bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + task_node=task_node, + workflow_node=workflow_node, + branch_node=branch_node, + gate_node=gate_node, + array_node=array_node, + ) + self._upstream = upstream_nodes + + @property + def task_node(self) -> Optional[FlyteTaskNode]: + return self._flyte_task_node + + @property + def flyte_entity(self) -> Union[FlyteTask, FlyteWorkflow, FlyteLaunchPlan, FlyteBranchNode]: + return self._flyte_entity + + @classmethod + def _promote_task_node(cls, t: FlyteTask) -> FlyteTaskNode: + return FlyteTaskNode.promote_from_model(t) + + @classmethod + def _promote_workflow_node( + cls, + wn: _workflow_model.WorkflowNode, + sub_workflows: Dict[id_models.Identifier, _workflow_model.WorkflowTemplate], + node_launch_plans: Dict[id_models.Identifier, _launch_plan_model.LaunchPlanSpec], + tasks: Dict[Identifier, FlyteTask], + converted_sub_workflows: Dict[id_models.Identifier, FlyteWorkflow], + ) -> Tuple[FlyteWorkflowNode, Dict[id_models.Identifier, FlyteWorkflow]]: + return FlyteWorkflowNode.promote_from_model( + wn, + sub_workflows, + node_launch_plans, + tasks, + converted_sub_workflows, + ) + + @classmethod + def promote_from_model( + cls, + model: _workflow_model.Node, + sub_workflows: Optional[Dict[id_models.Identifier, _workflow_model.WorkflowTemplate]], + node_launch_plans: Optional[Dict[id_models.Identifier, _launch_plan_model.LaunchPlanSpec]], + tasks: Dict[id_models.Identifier, FlyteTask], + converted_sub_workflows: Dict[id_models.Identifier, FlyteWorkflow], + ) -> Tuple[Optional[FlyteNode], Dict[id_models.Identifier, FlyteWorkflow]]: + node_model_id = model.id + # TODO: Consider removing + if id in {_constants.START_NODE_ID, _constants.END_NODE_ID}: + logger.warning(f"Should not call promote from model on a start node or end node {model}") + return None, converted_sub_workflows + + flyte_task_node, flyte_workflow_node, flyte_branch_node, flyte_gate_node, flyte_array_node = ( + None, + None, + None, + None, + None, + ) + if model.task_node is not None: + if model.task_node.reference_id not in tasks: + raise RuntimeError( + f"Remote Workflow closure does not have task with id {model.task_node.reference_id}." + ) + flyte_task_node = cls._promote_task_node(tasks[model.task_node.reference_id]) + elif model.workflow_node is not None: + flyte_workflow_node, converted_sub_workflows = cls._promote_workflow_node( + model.workflow_node, + sub_workflows, + node_launch_plans, + tasks, + converted_sub_workflows, + ) + elif model.branch_node is not None: + flyte_branch_node, converted_sub_workflows = FlyteBranchNode.promote_from_model( + model.branch_node, + sub_workflows, + node_launch_plans, + tasks, + converted_sub_workflows, + ) + elif model.gate_node is not None: + flyte_gate_node = FlyteGateNode.promote_from_model(model.gate_node) + elif model.array_node is not None: + flyte_array_node = FlyteArrayNode.promote_from_model(model.array_node) + # TODO: validate task in tasks + else: + raise _system_exceptions.FlyteSystemException( + f"Bad Node model, neither task nor workflow detected, node: {model}" + ) + + # When WorkflowTemplate models (containing node models) are returned by Admin, they've been compiled with a + # start node. In order to make the promoted FlyteWorkflow look the same, we strip the start-node text back out. + # TODO: Consider removing + for model_input in model.inputs: + if ( + model_input.binding.promise is not None + and model_input.binding.promise.node_id == _constants.START_NODE_ID + ): + model_input.binding.promise._node_id = _constants.GLOBAL_INPUT_NODE_ID + + return ( + cls( + id=node_model_id, + upstream_nodes=[], # set downstream, model doesn't contain this information + bindings=model.inputs, + metadata=model.metadata, + task_node=flyte_task_node, + workflow_node=flyte_workflow_node, + branch_node=flyte_branch_node, + gate_node=flyte_gate_node, + array_node=flyte_array_node, + ), + converted_sub_workflows, + ) + + @property + def upstream_nodes(self) -> List[FlyteNode]: + return self._upstream + + @property + def upstream_node_ids(self) -> List[str]: + return list(sorted(n.id for n in self.upstream_nodes)) + + def __repr__(self) -> str: + return f"Node(ID: {self.id})" + + +class FlyteWorkflow(_hash_mixin.HashOnReferenceMixin, RemoteEntity, WorkflowSpec): + """A class encapsulating a remote Flyte workflow.""" + + def __init__( + self, + id: id_models.Identifier, + nodes: List[FlyteNode], + interface, + output_bindings, + metadata, + metadata_defaults, + subworkflows: Optional[List[FlyteWorkflow]] = None, + tasks: Optional[List[FlyteTask]] = None, + launch_plans: Optional[Dict[id_models.Identifier, launch_plan_models.LaunchPlanSpec]] = None, + compiled_closure: Optional[compiler_models.CompiledWorkflowClosure] = None, + should_register: bool = False, + ): + # TODO: Remove check + for node in nodes: + for upstream in node.upstream_nodes: + if upstream.id is None: + raise _user_exceptions.FlyteAssertion( + "Some nodes contained in the workflow were not found in the workflow description. Please " + "ensure all nodes are either assigned to attributes within the class or an element in a " + "list, dict, or tuple which is stored as an attribute in the class." + ) + + self._flyte_sub_workflows = subworkflows + template_subworkflows = [] + if subworkflows: + template_subworkflows = [swf.template for swf in subworkflows] + + super(FlyteWorkflow, self).__init__( + template=_workflow_models.WorkflowTemplate( + id=id, + metadata=metadata, + metadata_defaults=metadata_defaults, + interface=interface, + nodes=nodes, + outputs=output_bindings, + ), + sub_workflows=template_subworkflows, + ) + self._flyte_nodes = nodes + + # Optional things that we save for ease of access when promoting from a model or CompiledWorkflowClosure + self._tasks = tasks + self._launch_plans = launch_plans + self._compiled_closure = compiled_closure + self._node_map = None + self._name = id.name + self._should_register = should_register + + @property + def name(self) -> str: + return self._name + + @property + def flyte_tasks(self) -> Optional[List[FlyteTask]]: + return self._tasks + + @property + def should_register(self) -> bool: + return self._should_register + + @property + def flyte_sub_workflows(self) -> List[FlyteWorkflow]: + return self._flyte_sub_workflows + + @property + def entity_type_text(self) -> str: + return "Workflow" + + @property + def resource_type(self): + return id_models.ResourceType.WORKFLOW + + @property + def flyte_nodes(self) -> List[FlyteNode]: + return self._flyte_nodes + + @property + def id(self) -> Identifier: + """ + This is an autogenerated id by the system. The id is globally unique across Flyte. + """ + return self.template.id + + @property + def metadata(self) -> WorkflowMetadata: + """ + This contains information on how to run the workflow. + """ + return self.template.metadata + + @property + def metadata_defaults(self) -> WorkflowMetadataDefaults: + """ + This contains information on how to run the workflow. + :rtype: WorkflowMetadataDefaults + """ + return self.template.metadata_defaults + + @property + def interface(self) -> TypedInterface: + """ + Defines a strongly typed interface for the Workflow (inputs, outputs). This can include some optional + parameters. + """ + return self.template.interface + + @property + def nodes(self) -> List[Node]: + """ + A list of nodes. In addition, "globals" is a special reserved node id that can be used to consume + workflow inputs + """ + return self.template.nodes + + @property + def outputs(self) -> List[Binding]: + """ + A list of output bindings that specify how to construct workflow outputs. Bindings can + pull node outputs or specify literals. All workflow outputs specified in the interface field must be bound + in order for the workflow to be validated. A workflow has an implicit dependency on all of its nodes + to execute successfully in order to bind final outputs. + """ + return self.template.outputs + + @property + def failure_node(self) -> Node: + """ + Node failure_node: A catch-all node. This node is executed whenever the execution engine determines the + workflow has failed. The interface of this node must match the Workflow interface with an additional input + named "error" of type pb.lyft.flyte.core.Error. + """ + return self.template.failure_node + + @classmethod + def get_non_system_nodes(cls, nodes: List[_workflow_models.Node]) -> List[_workflow_models.Node]: + return [n for n in nodes if n.id not in {_constants.START_NODE_ID, _constants.END_NODE_ID}] + + @classmethod + def _promote_node( + cls, + model: _workflow_model.Node, + sub_workflows: Optional[Dict[id_models.Identifier, _workflow_model.WorkflowTemplate]], + node_launch_plans: Optional[Dict[id_models.Identifier, _launch_plan_model.LaunchPlanSpec]], + tasks: Dict[id_models.Identifier, FlyteTask], + converted_sub_workflows: Dict[id_models.Identifier, FlyteWorkflow], + ) -> Tuple[Optional[FlyteNode], Dict[id_models.Identifier, FlyteWorkflow]]: + return FlyteNode.promote_from_model(model, sub_workflows, node_launch_plans, tasks, converted_sub_workflows) + + @classmethod + def promote_from_model( + cls, + base_model: _workflow_models.WorkflowTemplate, + sub_workflows: Optional[Dict[Identifier, _workflow_models.WorkflowTemplate]] = None, + tasks: Optional[Dict[Identifier, FlyteTask]] = None, + node_launch_plans: Optional[Dict[Identifier, launch_plan_models.LaunchPlanSpec]] = None, + ) -> FlyteWorkflow: + base_model_non_system_nodes = cls.get_non_system_nodes(base_model.nodes) + + node_map = {} + converted_sub_workflows = {} + for node in base_model_non_system_nodes: + flyte_node, converted_sub_workflows = cls._promote_node( + node, sub_workflows, node_launch_plans, tasks, converted_sub_workflows + ) + node_map[node.id] = flyte_node + + # Set upstream nodes for each node + for n in base_model_non_system_nodes: + current = node_map[n.id] + for upstream_id in n.upstream_node_ids: + upstream_node = node_map[upstream_id] + current._upstream.append(upstream_node) + + subworkflow_list = [] + if converted_sub_workflows: + subworkflow_list = [v for _, v in converted_sub_workflows.items()] + + task_list = [] + if tasks: + task_list = [t for _, t in tasks.items()] + + # No inputs/outputs specified, see the constructor for more information on the overrides. + wf = cls( + id=base_model.id, + nodes=list(node_map.values()), + metadata=base_model.metadata, + metadata_defaults=base_model.metadata_defaults, + interface=_interfaces.TypedInterface.promote_from_model(base_model.interface), + output_bindings=base_model.outputs, + subworkflows=subworkflow_list, + tasks=task_list, + launch_plans=node_launch_plans, + ) + + wf._node_map = node_map + + return wf + + @classmethod + def _promote_task(cls, t: _task_models.TaskTemplate) -> FlyteTask: + return FlyteTask.promote_from_model(t) + + @classmethod + def promote_from_closure( + cls, + closure: compiler_models.CompiledWorkflowClosure, + node_launch_plans: Optional[Dict[id_models, launch_plan_models.LaunchPlanSpec]] = None, + ): + """ + Extracts out the relevant portions of a FlyteWorkflow from a closure from the control plane. + + :param closure: This is the closure returned by Admin + :param node_launch_plans: The reason this exists is because the compiled closure doesn't have launch plans. + It only has subworkflows and tasks. Why this is unclear. If supplied, this map of launch plans will be + """ + sub_workflows = {sw.template.id: sw.template for sw in closure.sub_workflows} + tasks = {} + if closure.tasks: + tasks = {t.template.id: cls._promote_task(t.template) for t in closure.tasks} + + flyte_wf = cls.promote_from_model( + base_model=closure.primary.template, + sub_workflows=sub_workflows, + node_launch_plans=node_launch_plans, + tasks=tasks, + ) + flyte_wf._compiled_closure = closure + return flyte_wf + + +class FlyteLaunchPlan(hash_mixin.HashOnReferenceMixin, RemoteEntity, _launch_plan_models.LaunchPlanSpec): + """A class encapsulating a remote Flyte launch plan.""" + + def __init__(self, id, *args, **kwargs): + super(FlyteLaunchPlan, self).__init__(*args, **kwargs) + # Set all the attributes we expect this class to have + self._id = id + self._name = id.name + + # The interface is not set explicitly unless fetched in an engine context + self._interface = None + # If fetched when creating this object, can store it here. + self._flyte_workflow = None + + @property + def name(self) -> str: + return self._name + + @property + def flyte_workflow(self) -> Optional[FlyteWorkflow]: + return self._flyte_workflow + + @classmethod + def promote_from_model(cls, id: id_models.Identifier, model: _launch_plan_models.LaunchPlanSpec) -> FlyteLaunchPlan: + lp = cls( + id=id, + workflow_id=model.workflow_id, + default_inputs=_interface_models.ParameterMap(model.default_inputs.parameters), + fixed_inputs=model.fixed_inputs, + entity_metadata=model.entity_metadata, + labels=model.labels, + annotations=model.annotations, + auth_role=model.auth_role, + raw_output_data_config=model.raw_output_data_config, + max_parallelism=model.max_parallelism, + security_context=model.security_context, + ) + return lp + + @property + def id(self) -> id_models.Identifier: + return self._id + + @property + def is_scheduled(self) -> bool: + if self.entity_metadata.schedule.cron_expression: + return True + elif self.entity_metadata.schedule.rate and self.entity_metadata.schedule.rate.value: + return True + elif self.entity_metadata.schedule.cron_schedule and self.entity_metadata.schedule.cron_schedule.schedule: + return True + else: + return False + + @property + def workflow_id(self) -> id_models.Identifier: + return self._workflow_id + + @property + def interface(self) -> Optional[_interface.TypedInterface]: + """ + The interface is not technically part of the admin.LaunchPlanSpec in the IDL, however the workflow ID is, and + from the workflow ID, fetch will fill in the interface. This is nice because then you can __call__ the= + object and get a node. + """ + return self._interface + + @property + def resource_type(self) -> id_models.ResourceType: + return id_models.ResourceType.LAUNCH_PLAN + + @property + def entity_type_text(self) -> str: + return "Launch Plan" + + def compile(self, ctx: FlyteContext, *args, **kwargs): + fixed_input_lits = self.fixed_inputs.literals or {} + default_input_params = self.default_inputs.parameters or {} + return create_and_link_node_from_remote( + ctx, + entity=self, + _inputs_not_allowed=set(fixed_input_lits.keys()), + _ignorable_inputs=set(default_input_params.keys()), + **kwargs, + ) # noqa + + def __repr__(self) -> str: + return f"FlyteLaunchPlan(ID: {self.id} Interface: {self.interface}) - Spec {super().__repr__()})" diff --git a/flytekit/flytekit/remote/executions.py b/flytekit/flytekit/remote/executions.py new file mode 100644 index 0000000000..292b6f0218 --- /dev/null +++ b/flytekit/flytekit/remote/executions.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import Dict, List, Optional, Union + +from flytekit.core.type_engine import LiteralsResolver +from flytekit.exceptions import user as user_exceptions +from flytekit.models import execution as execution_models +from flytekit.models import node_execution as node_execution_models +from flytekit.models.admin import task_execution as admin_task_execution_models +from flytekit.models.core import execution as core_execution_models +from flytekit.remote.entities import FlyteTask, FlyteWorkflow + + +class RemoteExecutionBase(object): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._inputs: Optional[LiteralsResolver] = None + self._outputs: Optional[LiteralsResolver] = None + + @property + def inputs(self) -> Optional[LiteralsResolver]: + return self._inputs + + @property + @abstractmethod + def error(self) -> core_execution_models.ExecutionError: + ... + + @property + @abstractmethod + def is_done(self) -> bool: + ... + + @property + def outputs(self) -> Optional[LiteralsResolver]: + """ + :return: Returns the outputs LiteralsResolver to the execution + :raises: ``FlyteAssertion`` error if execution is in progress or execution ended in error. + """ + if not self.is_done: + raise user_exceptions.FlyteAssertion( + "Please wait until the execution has completed before requesting the outputs." + ) + if self.error: + raise user_exceptions.FlyteAssertion("Outputs could not be found because the execution ended in failure.") + + return self._outputs + + +class FlyteTaskExecution(RemoteExecutionBase, admin_task_execution_models.TaskExecution): + """A class encapsulating a task execution being run on a Flyte remote backend.""" + + def __init__(self, *args, **kwargs): + super(FlyteTaskExecution, self).__init__(*args, **kwargs) + self._flyte_task = None + + @property + def task(self) -> Optional[FlyteTask]: + return self._flyte_task + + @property + def is_done(self) -> bool: + """Whether or not the execution is complete.""" + return self.closure.phase in { + core_execution_models.TaskExecutionPhase.ABORTED, + core_execution_models.TaskExecutionPhase.FAILED, + core_execution_models.TaskExecutionPhase.SUCCEEDED, + } + + @property + def error(self) -> Optional[core_execution_models.ExecutionError]: + """ + If execution is in progress, raise an exception. Otherwise, return None if no error was present upon + reaching completion. + """ + if not self.is_done: + raise user_exceptions.FlyteAssertion( + "Please what until the task execution has completed before requesting error information." + ) + return self.closure.error + + @classmethod + def promote_from_model(cls, base_model: admin_task_execution_models.TaskExecution) -> "FlyteTaskExecution": + return cls( + closure=base_model.closure, + id=base_model.id, + input_uri=base_model.input_uri, + is_parent=base_model.is_parent, + ) + + +class FlyteWorkflowExecution(RemoteExecutionBase, execution_models.Execution): + """A class encapsulating a workflow execution being run on a Flyte remote backend.""" + + def __init__(self, *args, **kwargs): + super(FlyteWorkflowExecution, self).__init__(*args, **kwargs) + self._node_executions = None + self._flyte_workflow: Optional[FlyteWorkflow] = None + + @property + def flyte_workflow(self) -> Optional[FlyteWorkflow]: + return self._flyte_workflow + + @property + def node_executions(self) -> Dict[str, "FlyteNodeExecution"]: + """Get a dictionary of node executions that are a part of this workflow execution.""" + return self._node_executions or {} + + @property + def error(self) -> core_execution_models.ExecutionError: + """ + If execution is in progress, raise an exception. Otherwise, return None if no error was present upon + reaching completion. + """ + if not self.is_done: + raise user_exceptions.FlyteAssertion( + "Please wait until a workflow has completed before checking for an error." + ) + return self.closure.error + + @property + def is_done(self) -> bool: + """ + Whether or not the execution is complete. + """ + return self.closure.phase in { + core_execution_models.WorkflowExecutionPhase.ABORTED, + core_execution_models.WorkflowExecutionPhase.FAILED, + core_execution_models.WorkflowExecutionPhase.SUCCEEDED, + core_execution_models.WorkflowExecutionPhase.TIMED_OUT, + } + + @classmethod + def promote_from_model(cls, base_model: execution_models.Execution) -> "FlyteWorkflowExecution": + return cls( + closure=base_model.closure, + id=base_model.id, + spec=base_model.spec, + ) + + +class FlyteNodeExecution(RemoteExecutionBase, node_execution_models.NodeExecution): + """A class encapsulating a node execution being run on a Flyte remote backend.""" + + def __init__(self, *args, **kwargs): + super(FlyteNodeExecution, self).__init__(*args, **kwargs) + self._task_executions = None + self._workflow_executions = [] + self._underlying_node_executions = None + self._interface = None + self._flyte_node = None + + @property + def task_executions(self) -> List[FlyteTaskExecution]: + return self._task_executions or [] + + @property + def workflow_executions(self) -> List[FlyteWorkflowExecution]: + return self._workflow_executions + + @property + def subworkflow_node_executions(self) -> Dict[str, FlyteNodeExecution]: + """ + This returns underlying node executions in instances where the current node execution is + a parent node. This happens when it's either a static or dynamic subworkflow. + """ + return ( + {} + if self._underlying_node_executions is None + else {n.id.node_id: n for n in self._underlying_node_executions} + ) + + @property + def executions(self) -> List[Union[FlyteTaskExecution, FlyteWorkflowExecution]]: + return self.task_executions or self._underlying_node_executions or [] + + @property + def error(self) -> core_execution_models.ExecutionError: + """ + If execution is in progress, raise an exception. Otherwise, return None if no error was present upon + reaching completion. + """ + if not self.is_done: + raise user_exceptions.FlyteAssertion( + "Please wait until the node execution has completed before requesting error information." + ) + return self.closure.error + + @property + def is_done(self) -> bool: + """Whether or not the execution is complete.""" + return self.closure.phase in { + core_execution_models.NodeExecutionPhase.ABORTED, + core_execution_models.NodeExecutionPhase.FAILED, + core_execution_models.NodeExecutionPhase.SKIPPED, + core_execution_models.NodeExecutionPhase.SUCCEEDED, + core_execution_models.NodeExecutionPhase.TIMED_OUT, + } + + @classmethod + def promote_from_model(cls, base_model: node_execution_models.NodeExecution) -> "FlyteNodeExecution": + return cls( + closure=base_model.closure, id=base_model.id, input_uri=base_model.input_uri, metadata=base_model.metadata + ) + + @property + def interface(self) -> "flytekit.remote.interface.TypedInterface": + """ + Return the interface of the task or subworkflow associated with this node execution. + """ + return self._interface diff --git a/flytekit/flytekit/remote/interface.py b/flytekit/flytekit/remote/interface.py new file mode 100644 index 0000000000..df61c8e336 --- /dev/null +++ b/flytekit/flytekit/remote/interface.py @@ -0,0 +1,11 @@ +from flytekit.models import interface as _interface_models + + +class TypedInterface(_interface_models.TypedInterface): + @classmethod + def promote_from_model(cls, model): + """ + :param flytekit.models.interface.TypedInterface model: + :rtype: TypedInterface + """ + return cls(model.inputs, model.outputs) diff --git a/flytekit/flytekit/remote/lazy_entity.py b/flytekit/flytekit/remote/lazy_entity.py new file mode 100644 index 0000000000..1df2197329 --- /dev/null +++ b/flytekit/flytekit/remote/lazy_entity.py @@ -0,0 +1,67 @@ +import typing +from threading import Lock + +from flytekit import FlyteContext +from flytekit.remote.remote_callable import RemoteEntity + +T = typing.TypeVar("T", bound=RemoteEntity) + + +class LazyEntity(RemoteEntity, typing.Generic[T]): + """ + Fetches the entity when the entity is called or when the entity is retrieved. + The entity is derived from RemoteEntity so that it behaves exactly like the mimicked entity. + """ + + def __init__(self, name: str, getter: typing.Callable[[], T], *args, **kwargs): + super().__init__(*args, **kwargs) + self._entity = None + self._getter = getter + self._name = name + if not self._getter: + raise ValueError("getter method is required to create a Lazy loadable Remote Entity.") + self._mutex = Lock() + + @property + def name(self) -> str: + return self._name + + def entity_fetched(self) -> bool: + with self._mutex: + return self._entity is not None + + @property + def entity(self) -> T: + """ + If not already fetched / available, then the entity will be force fetched. + """ + with self._mutex: + if self._entity is None: + try: + self._entity = self._getter() + except AttributeError as e: + raise RuntimeError( + f"Error downloading the entity {self._name}, (check original exception...)" + ) from e + return self._entity + + def __getattr__(self, item: str) -> typing.Any: + """ + Forwards all other attributes to entity, causing the entity to be fetched! + """ + return getattr(self.entity, item) + + def compile(self, ctx: FlyteContext, *args, **kwargs): + return self.entity.compile(ctx, *args, **kwargs) + + def __call__(self, *args, **kwargs): + """ + Forwards the call to the underlying entity. The entity will be fetched if not already present + """ + return self.entity(*args, **kwargs) + + def __repr__(self) -> str: + return str(self) + + def __str__(self) -> str: + return f"Promise for entity [{self._name}]" diff --git a/flytekit/flytekit/remote/remote.py b/flytekit/flytekit/remote/remote.py new file mode 100644 index 0000000000..076e73efad --- /dev/null +++ b/flytekit/flytekit/remote/remote.py @@ -0,0 +1,2085 @@ +""" +This module provides the ``FlyteRemote`` object, which is the end-user's main starting point for interacting +with a Flyte backend in an interactive and programmatic way. This of this experience as kind of like the web UI +but in Python object form. +""" +from __future__ import annotations + +import base64 +import configparser +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 click +import fsspec +import requests +from flyteidl.admin.signal_pb2 import Signal, SignalListRequest, SignalSetRequest +from flyteidl.core import literals_pb2 + +from flytekit import ImageSpec +from flytekit.clients.friendly import SynchronousFlyteClient +from flytekit.clients.helpers import iterate_node_executions, iterate_task_executions +from flytekit.configuration import Config, FastSerializationSettings, ImageConfig, SerializationSettings +from flytekit.core import constants, utils +from flytekit.core.artifact import Artifact +from flytekit.core.base_task import PythonTask +from flytekit.core.context_manager import FlyteContext, FlyteContextManager +from flytekit.core.data_persistence import FileAccessProvider +from flytekit.core.launch_plan import LaunchPlan +from flytekit.core.python_auto_container import PythonAutoContainerTask +from flytekit.core.reference_entity import ReferenceSpec +from flytekit.core.type_engine import LiteralsResolver, TypeEngine +from flytekit.core.workflow import WorkflowBase, WorkflowFailurePolicy +from flytekit.exceptions import user as user_exceptions +from flytekit.exceptions.user import ( + FlyteEntityAlreadyExistsException, + FlyteEntityNotExistException, + FlyteValueException, +) +from flytekit.loggers import logger +from flytekit.models import common as common_models +from flytekit.models import filters as filter_models +from flytekit.models import launch_plan as launch_plan_models +from flytekit.models import literals as literal_models +from flytekit.models import task as task_models +from flytekit.models import types as type_models +from flytekit.models.admin import common as admin_common_models +from flytekit.models.admin import workflow as admin_workflow_models +from flytekit.models.admin.common import Sort +from flytekit.models.core import workflow as workflow_model +from flytekit.models.core.identifier import Identifier, ResourceType, SignalIdentifier, WorkflowExecutionIdentifier +from flytekit.models.core.workflow import NodeMetadata +from flytekit.models.execution import ( + ClusterAssignment, + ExecutionMetadata, + ExecutionSpec, + NodeExecutionGetDataResponse, + NotificationList, + WorkflowExecutionGetDataResponse, +) +from flytekit.models.launch_plan import LaunchPlanState +from flytekit.models.literals import Literal, LiteralMap +from flytekit.remote.backfill import create_backfill_workflow +from flytekit.remote.data import download_literal +from flytekit.remote.entities import FlyteLaunchPlan, FlyteNode, FlyteTask, FlyteTaskNode, FlyteWorkflow +from flytekit.remote.executions import FlyteNodeExecution, FlyteTaskExecution, FlyteWorkflowExecution +from flytekit.remote.interface import TypedInterface +from flytekit.remote.lazy_entity import LazyEntity +from flytekit.remote.remote_callable import RemoteEntity +from flytekit.remote.remote_fs import get_flyte_fs +from flytekit.tools.fast_registration import fast_package +from flytekit.tools.interactive import ipython_check +from flytekit.tools.script_mode import compress_scripts, hash_file +from flytekit.tools.translator import ( + FlyteControlPlaneEntity, + FlyteLocalEntity, + Options, + get_serializable, + get_serializable_launch_plan, +) + +if typing.TYPE_CHECKING: + try: + from IPython.core.display import HTML + except ImportError: + ... + +ExecutionDataResponse = typing.Union[WorkflowExecutionGetDataResponse, NodeExecutionGetDataResponse] + +MOST_RECENT_FIRST = admin_common_models.Sort("created_at", admin_common_models.Sort.Direction.DESCENDING) + + +class RegistrationSkipped(Exception): + """ + RegistrationSkipped error is raised when trying to register an entity that is not registrable. + """ + + pass + + +@dataclass +class ResolvedIdentifiers: + project: str + domain: str + name: str + version: str + + +def _get_latest_version(list_entities_method: typing.Callable, project: str, domain: str, name: str): + named_entity = common_models.NamedEntityIdentifier(project, domain, name) + entity_list, _ = list_entities_method( + named_entity, + limit=1, + sort_by=Sort("created_at", Sort.Direction.DESCENDING), + ) + admin_entity = None if not entity_list else entity_list[0] + if not admin_entity: + raise user_exceptions.FlyteEntityNotExistException("Named entity {} not found".format(named_entity)) + return admin_entity.id.version + + +def _get_entity_identifier( + list_entities_method: typing.Callable, + resource_type: int, # from flytekit.models.core.identifier.ResourceType + project: str, + domain: str, + name: str, + version: typing.Optional[str] = None, +): + return Identifier( + resource_type, + project, + domain, + name, + version if version is not None else _get_latest_version(list_entities_method, project, domain, name), + ) + + +def _get_git_repo_url(source_path): + """ + Get git repo URL from remote.origin.url + """ + try: + git_config = source_path / ".git" / "config" + if not git_config.exists(): + raise ValueError(f"{source_path} is not a git repo") + + config = configparser.ConfigParser() + config.read(git_config) + url = config['remote "origin"']["url"] + + if url.startswith("git@"): + # url format: git@github.com:flytekit/flytekit.git + prefix_len, suffix_len = len("git@"), len(".git") + return url[prefix_len:-suffix_len].replace(":", "/") + elif url.startswith("https://"): + # url format: https://github.com/flytekit/flytekit + prefix_len = len("https://") + return url[prefix_len:] + elif url.startswith("http://"): + # url format: http://github.com/flytekit/flytekit + prefix_len = len("http://") + return url[prefix_len:] + else: + raise ValueError("Unable to parse url") + + except Exception as e: + logger.debug(str(e)) + return "" + + +class FlyteRemote(object): + """Main entrypoint for programmatically accessing a Flyte remote backend. + + The term 'remote' is synonymous with 'backend' or 'deployment' and refers to a hosted instance of the + Flyte platform, which comes with a Flyte Admin server on some known URI. + """ + + def __init__( + self, + config: Config, + default_project: typing.Optional[str] = None, + default_domain: typing.Optional[str] = None, + data_upload_location: str = "flyte://my-s3-bucket/", + **kwargs, + ): + """Initialize a FlyteRemote object. + + :type kwargs: All arguments that can be passed to create the SynchronousFlyteClient. These are usually grpc + parameters, if you want to customize credentials, ssl handling etc. + :param default_project: default project to use when fetching or executing flyte entities. + :param default_domain: default domain to use when fetching or executing flyte entities. + :param data_upload_location: this is where all the default data will be uploaded when providing inputs. + The default location - `s3://my-s3-bucket/data` works for sandbox/demo environment. Please override this for non-sandbox cases. + """ + if config is None or config.platform is None or config.platform.endpoint is None: + raise user_exceptions.FlyteAssertion("Flyte endpoint should be provided.") + + if data_upload_location is None: + data_upload_location = FlyteContext.current_context().file_access.raw_output_prefix + self._kwargs = kwargs + self._client_initialized = False + self._config = config + # read config files, env vars, host, ssl options for admin client + self._default_project = default_project + self._default_domain = default_domain + + fsspec.register_implementation("flyte", get_flyte_fs(remote=self), clobber=True) + + self._file_access = FileAccessProvider( + local_sandbox_dir=os.path.join(config.local_sandbox_path, "control_plane_metadata"), + raw_output_prefix=data_upload_location, + data_config=config.data_config, + ) + + # Save the file access object locally, build a context for it and save that as well. + self._ctx = FlyteContextManager.current_context().with_file_access(self._file_access).build() + + @property + def context(self) -> FlyteContext: + return self._ctx + + @property + def client(self) -> SynchronousFlyteClient: + """Return a SynchronousFlyteClient for additional operations.""" + if not self._client_initialized: + self._client = SynchronousFlyteClient(self.config.platform, **self._kwargs) + self._client_initialized = True + return self._client + + @property + def default_project(self) -> str: + """Default project to use when fetching or executing flyte entities.""" + return self._default_project + + @property + def default_domain(self) -> str: + """Default project to use when fetching or executing flyte entities.""" + return self._default_domain + + @property + def config(self) -> Config: + """Image config.""" + return self._config + + @property + def file_access(self) -> FileAccessProvider: + """File access provider to use for offloading non-literal inputs/outputs.""" + return self._file_access + + def get( + self, flyte_uri: typing.Optional[str] = None + ) -> typing.Optional[typing.Union[LiteralsResolver, Literal, HTML, bytes]]: + """ + General function that works with flyte tiny urls. This can return outputs (in the form of LiteralsResolver, or + individual Literals for singular requests), or HTML if passed a deck link, or bytes containing HTML, + if ipython is not available locally. + """ + if flyte_uri is None: + raise user_exceptions.FlyteUserException("flyte_uri cannot be empty") + ctx = self._ctx or FlyteContextManager.current_context() + try: + data_response = self.client.get_data(flyte_uri) + + if data_response.HasField("literal_map"): + lm = LiteralMap.from_flyte_idl(data_response.literal_map) + return LiteralsResolver(lm.literals) + elif data_response.HasField("literal"): + return Literal.from_flyte_idl(data_response.literal) + elif data_response.HasField("pre_signed_urls"): + if len(data_response.pre_signed_urls.signed_url) == 0: + raise ValueError(f"Flyte url {flyte_uri} resolved to empty download link") + d = data_response.pre_signed_urls.signed_url[0] + logger.debug(f"Download link is {d}") + fs = ctx.file_access.get_filesystem_for_path(d) + + # If the venv has IPython, then return IPython's HTML + if ipython_check(): + from IPython.core.display import HTML + + logger.debug(f"IPython found, returning HTML from {flyte_uri}") + with fs.open(d, "rb") as r: + html = HTML(str(r.read())) + return html + # If not return bytes + else: + logger.debug(f"IPython not found, returning HTML as bytes from {flyte_uri}") + return fs.open(d, "rb").read() + + except user_exceptions.FlyteUserException as e: + logger.info(f"Error from Flyte backend when trying to fetch data: {e.__cause__}") + + logger.info(f"Nothing found from {flyte_uri}") + + def remote_context(self): + """Context manager with remote-specific configuration.""" + return FlyteContextManager.with_context( + FlyteContextManager.current_context().with_file_access(self.file_access) + ) + + def fetch_task_lazy( + self, project: str = None, domain: str = None, name: str = None, version: str = None + ) -> LazyEntity: + """ + Similar to fetch_task, just that it returns a LazyEntity, which will fetch the workflow lazily. + """ + if name is None: + raise user_exceptions.FlyteAssertion("the 'name' argument must be specified.") + + def _fetch(): + return self.fetch_task(project=project, domain=domain, name=name, version=version) + + return LazyEntity(name=name, getter=_fetch) + + def fetch_task(self, project: str = None, domain: str = None, name: str = None, version: str = None) -> FlyteTask: + """Fetch a task entity from flyte admin. + + :param project: fetch entity from this project. If None, uses the default_project attribute. + :param domain: fetch entity from this domain. If None, uses the default_domain attribute. + :param name: fetch entity with matching name. + :param version: fetch entity with matching version. If None, gets the latest version of the entity. + :returns: :class:`~flytekit.remote.tasks.task.FlyteTask` + + :raises: FlyteAssertion if name is None + """ + if name is None: + raise user_exceptions.FlyteAssertion("the 'name' argument must be specified.") + task_id = _get_entity_identifier( + self.client.list_tasks_paginated, + ResourceType.TASK, + project or self.default_project, + domain or self.default_domain, + name, + version, + ) + admin_task = self.client.get_task(task_id) + flyte_task = FlyteTask.promote_from_model(admin_task.closure.compiled_task.template) + flyte_task.template._id = task_id + return flyte_task + + def fetch_workflow_lazy( + self, project: str = None, domain: str = None, name: str = None, version: str = None + ) -> LazyEntity[FlyteWorkflow]: + """ + Similar to fetch_workflow, just that it returns a LazyEntity, which will fetch the workflow lazily. + """ + if name is None: + raise user_exceptions.FlyteAssertion("the 'name' argument must be specified.") + + def _fetch(): + return self.fetch_workflow(project, domain, name, version) + + return LazyEntity(name=name, getter=_fetch) + + def fetch_workflow( + self, project: str = None, domain: str = None, name: str = None, version: str = None + ) -> FlyteWorkflow: + """ + Fetch a workflow entity from flyte admin. + :param project: fetch entity from this project. If None, uses the default_project attribute. + :param domain: fetch entity from this domain. If None, uses the default_domain attribute. + :param name: fetch entity with matching name. + :param version: fetch entity with matching version. If None, gets the latest version of the entity. + :raises: FlyteAssertion if name is None + """ + if name is None: + raise user_exceptions.FlyteAssertion("the 'name' argument must be specified.") + workflow_id = _get_entity_identifier( + self.client.list_workflows_paginated, + ResourceType.WORKFLOW, + project or self.default_project, + domain or self.default_domain, + name, + version, + ) + + admin_workflow = self.client.get_workflow(workflow_id) + compiled_wf = admin_workflow.closure.compiled_workflow + + wf_templates = [compiled_wf.primary.template] + wf_templates.extend([swf.template for swf in compiled_wf.sub_workflows]) + + node_launch_plans = {} + # TODO: Inspect branch nodes for launch plans + for wf_template in wf_templates: + for node in FlyteWorkflow.get_non_system_nodes(wf_template.nodes): + if node.workflow_node is not None and node.workflow_node.launchplan_ref is not None: + lp_ref = node.workflow_node.launchplan_ref + if node.workflow_node.launchplan_ref not in node_launch_plans: + admin_launch_plan = self.client.get_launch_plan(lp_ref) + node_launch_plans[node.workflow_node.launchplan_ref] = admin_launch_plan.spec + + return FlyteWorkflow.promote_from_closure(compiled_wf, node_launch_plans) + + def fetch_launch_plan( + self, project: str = None, domain: str = None, name: str = None, version: str = None + ) -> FlyteLaunchPlan: + """Fetch a launchplan entity from flyte admin. + + :param project: fetch entity from this project. If None, uses the default_project attribute. + :param domain: fetch entity from this domain. If None, uses the default_domain attribute. + :param name: fetch entity with matching name. + :param version: fetch entity with matching version. If None, gets the latest version of the entity. + :returns: :class:`~flytekit.remote.launch_plan.FlyteLaunchPlan` + + :raises: FlyteAssertion if name is None + """ + if name is None: + raise user_exceptions.FlyteAssertion("the 'name' argument must be specified.") + launch_plan_id = _get_entity_identifier( + self.client.list_launch_plans_paginated, + ResourceType.LAUNCH_PLAN, + project or self.default_project, + domain or self.default_domain, + name, + version, + ) + admin_launch_plan = self.client.get_launch_plan(launch_plan_id) + flyte_launch_plan = FlyteLaunchPlan.promote_from_model(launch_plan_id, admin_launch_plan.spec) + + wf_id = flyte_launch_plan.workflow_id + workflow = self.fetch_workflow(wf_id.project, wf_id.domain, wf_id.name, wf_id.version) + flyte_launch_plan._interface = workflow.interface + flyte_launch_plan._flyte_workflow = workflow + + return flyte_launch_plan + + def fetch_execution(self, project: str = None, domain: str = None, name: str = None) -> FlyteWorkflowExecution: + """Fetch a workflow execution entity from flyte admin. + + :param project: fetch entity from this project. If None, uses the default_project attribute. + :param domain: fetch entity from this domain. If None, uses the default_domain attribute. + :param name: fetch entity with matching name. + :returns: :class:`~flytekit.remote.workflow_execution.FlyteWorkflowExecution` + + :raises: FlyteAssertion if name is None + """ + if name is None: + raise user_exceptions.FlyteAssertion("the 'name' argument must be specified.") + execution = FlyteWorkflowExecution.promote_from_model( + self.client.get_execution( + WorkflowExecutionIdentifier( + project or self.default_project, + domain or self.default_domain, + name, + ) + ) + ) + return self.sync_execution(execution) + + ###################### + # Listing Entities # + ###################### + + def list_signals( + self, + execution_name: str, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + limit: int = 100, + filters: typing.Optional[typing.List[filter_models.Filter]] = None, + ) -> typing.List[Signal]: + """ + :param execution_name: The name of the execution. This is the tailend of the URL when looking at the workflow execution. + :param project: The execution project, will default to the Remote's default project. + :param domain: The execution domain, will default to the Remote's default domain. + :param limit: The number of signals to fetch + :param filters: Optional list of filters + """ + wf_exec_id = WorkflowExecutionIdentifier( + project=project or self.default_project, domain=domain or self.default_domain, name=execution_name + ) + req = SignalListRequest(workflow_execution_id=wf_exec_id.to_flyte_idl(), limit=limit, filters=filters) + resp = self.client.list_signals(req) + s = resp.signals + return s + + def set_signal( + self, + signal_id: str, + execution_name: str, + value: typing.Union[literal_models.Literal, typing.Any], + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + python_type: typing.Optional[typing.Type] = None, + literal_type: typing.Optional[type_models.LiteralType] = None, + ): + """ + :param signal_id: The name of the signal, this is the key used in the approve() or wait_for_input() call. + :param execution_name: The name of the execution. This is the tail-end of the URL when looking + at the workflow execution. + :param value: This is either a Literal or a Python value which FlyteRemote will invoke the TypeEngine to + convert into a Literal. This argument is only value for wait_for_input type signals. + :param project: The execution project, will default to the Remote's default project. + :param domain: The execution domain, will default to the Remote's default domain. + :param python_type: Provide a python type to help with conversion if the value you provided is not a Literal. + :param literal_type: Provide a Flyte literal type to help with conversion if the value you provided + is not a Literal + """ + wf_exec_id = WorkflowExecutionIdentifier( + project=project or self.default_project, domain=domain or self.default_domain, name=execution_name + ) + if isinstance(value, Literal): + logger.debug(f"Using provided {value} as existing Literal value") + lit = value + else: + lt = literal_type or ( + TypeEngine.to_literal_type(python_type) if python_type else TypeEngine.to_literal_type(type(value)) + ) + lit = TypeEngine.to_literal(self.context, value, python_type or type(value), lt) + logger.debug(f"Converted {value} to literal {lit} using literal type {lt}") + + req = SignalSetRequest(id=SignalIdentifier(signal_id, wf_exec_id).to_flyte_idl(), value=lit.to_flyte_idl()) + + # Response is empty currently, nothing to give back to the user. + self.client.set_signal(req) + + def recent_executions( + self, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + limit: typing.Optional[int] = 100, + ) -> typing.List[FlyteWorkflowExecution]: + # Ignore token for now + exec_models, _ = self.client.list_executions_paginated( + project or self.default_project, + domain or self.default_domain, + limit, + sort_by=MOST_RECENT_FIRST, + ) + return [FlyteWorkflowExecution.promote_from_model(e) for e in exec_models] + + def list_tasks_by_version( + self, + version: str, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + limit: typing.Optional[int] = 100, + ) -> typing.List[FlyteTask]: + if not version: + raise ValueError("Must specify a version") + + named_entity_id = common_models.NamedEntityIdentifier( + project=project or self.default_project, + domain=domain or self.default_domain, + ) + # Ignore token for now + t_models, _ = self.client.list_tasks_paginated( + named_entity_id, + filters=[filter_models.Filter.from_python_std(f"eq(version,{version})")], + limit=limit, + ) + return [FlyteTask.promote_from_model(t.closure.compiled_task.template) for t in t_models] + + ##################### + # Register Entities # + ##################### + + def _resolve_identifier(self, t: int, name: str, version: str, ss: SerializationSettings) -> Identifier: + ident = Identifier( + resource_type=t, + project=ss.project if ss and ss.project else self.default_project, + domain=ss.domain if ss and ss.domain else self.default_domain, + name=name, + version=version or ss.version, + ) + if not ident.project or not ident.domain or not ident.name or not ident.version: + raise ValueError( + f"To register a new {ident.resource_type}, (project, domain, name, version) required, " + f"received ({ident.project}, {ident.domain}, {ident.name}, {ident.version})." + ) + return ident + + def raw_register( + self, + cp_entity: FlyteControlPlaneEntity, + settings: SerializationSettings, + version: str, + create_default_launchplan: bool = True, + options: Options = None, + og_entity: FlyteLocalEntity = None, + ) -> typing.Optional[Identifier]: + """ + Raw register method, can be used to register control plane entities. Usually if you have a Flyte Entity like a + WorkflowBase, Task, LaunchPlan then use other methods. This should be used only if you have already serialized entities + + :param cp_entity: The controlplane "serializable" version of a flyte entity. This is in the form that FlyteAdmin + understands. + :param settings: SerializationSettings to be used for registration - especially to identify the id + :param version: Version to be registered + :param create_default_launchplan: boolean that indicates if a default launch plan should be created + :param options: Options to be used if registering a default launch plan + :param og_entity: Pass in the original workflow (flytekit type) if create_default_launchplan is true + :return: Identifier of the created entity + """ + if isinstance(cp_entity, RemoteEntity): + if isinstance(cp_entity, (FlyteWorkflow, FlyteTask)): + if not cp_entity.should_register: + logger.debug(f"Skipping registration of remote entity: {cp_entity.name}") + raise RegistrationSkipped(f"Remote task/Workflow {cp_entity.name} is not registrable.") + else: + logger.debug(f"Skipping registration of remote entity: {cp_entity.name}") + raise RegistrationSkipped(f"Remote task/Workflow {cp_entity.name} is not registrable.") + + if isinstance( + cp_entity, + ( + workflow_model.Node, + workflow_model.WorkflowNode, + workflow_model.BranchNode, + workflow_model.TaskNode, + ), + ): + logger.debug("Ignoring nodes for registration.") + return None + + elif isinstance(cp_entity, ReferenceSpec): + logger.debug(f"Skipping registration of Reference entity, name: {cp_entity.template.id.name}") + return None + + if isinstance(cp_entity, task_models.TaskSpec): + if isinstance(cp_entity, FlyteTask): + version = cp_entity.id.version + ident = self._resolve_identifier(ResourceType.TASK, cp_entity.template.id.name, version, settings) + try: + self.client.create_task(task_identifer=ident, task_spec=cp_entity) + except FlyteEntityAlreadyExistsException: + logger.info(f" {ident} Already Exists!") + return ident + + if isinstance(cp_entity, admin_workflow_models.WorkflowSpec): + if isinstance(cp_entity, FlyteWorkflow): + version = cp_entity.id.version + ident = self._resolve_identifier(ResourceType.WORKFLOW, cp_entity.template.id.name, version, settings) + try: + self.client.create_workflow(workflow_identifier=ident, workflow_spec=cp_entity) + except FlyteEntityAlreadyExistsException: + logger.info(f" {ident} Already Exists!") + + if create_default_launchplan: + if not og_entity: + raise user_exceptions.FlyteValueException( + "To create default launch plan, please pass in the original flytekit workflow `og_entity`" + ) + + # Let us also create a default launch-plan, ideally the default launchplan should be added + # to the orderedDict, but we do not. + self.file_access._get_upload_signed_url_fn = functools.partial( + self.client.get_upload_signed_url, project=settings.project, domain=settings.domain + ) + default_lp = LaunchPlan.get_default_launch_plan(self.context, og_entity) + lp_entity = get_serializable_launch_plan( + OrderedDict(), + settings, + default_lp, + recurse_downstream=False, + options=options, + ) + try: + self.client.create_launch_plan(lp_entity.id, lp_entity.spec) + except FlyteEntityAlreadyExistsException: + logger.info(f" {lp_entity.id} Already Exists!") + return ident + + if isinstance(cp_entity, launch_plan_models.LaunchPlan): + ident = self._resolve_identifier(ResourceType.LAUNCH_PLAN, cp_entity.id.name, version, settings) + try: + self.client.create_launch_plan(launch_plan_identifer=ident, launch_plan_spec=cp_entity.spec) + except FlyteEntityAlreadyExistsException: + logger.info(f" {ident} Already Exists!") + return ident + + raise AssertionError(f"Unknown entity of type {type(cp_entity)}") + + def _serialize_and_register( + self, + entity: FlyteLocalEntity, + settings: typing.Optional[SerializationSettings], + version: str, + options: typing.Optional[Options] = None, + create_default_launchplan: bool = True, + ) -> Identifier: + """ + This method serializes and register the given Flyte entity + :return: Identifier of the registered entity + """ + m = OrderedDict() + # Create dummy serialization settings for now. + # TODO: Clean this up by using lazy usage of serialization settings in translator.py + serialization_settings = settings + is_dummy_serialization_setting = False + if not settings: + serialization_settings = SerializationSettings( + ImageConfig.auto_default_image(), + project=self.default_project, + domain=self.default_domain, + 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 + for entity, cp_entity in m.items(): + if not isinstance(cp_entity, admin_workflow_models.WorkflowSpec) and is_dummy_serialization_setting: + # Only in the case of workflows can we use the dummy serialization settings. + raise user_exceptions.FlyteValueException( + settings, + f"No serialization settings set, but workflow contains entities that need to be registered. {cp_entity.id.name}", + ) + + try: + ident = self.raw_register( + cp_entity, + settings=settings, + version=version, + create_default_launchplan=create_default_launchplan, + options=options, + og_entity=entity, + ) + except RegistrationSkipped: + pass + + return ident + + def register_task( + self, entity: PythonTask, serialization_settings: SerializationSettings, version: typing.Optional[str] = None + ) -> FlyteTask: + """ + Register a qualified task (PythonTask) with Remote + For any conflicting parameters method arguments are regarded as overrides + + :param entity: PythonTask can be either @task or a instance of a Task class + :param serialization_settings: Settings that will be used to override various serialization parameters. + :param version: version that will be used to register. If not specified will default to using the serialization settings default + :return: + """ + ident = self._serialize_and_register(entity=entity, settings=serialization_settings, version=version) + ft = self.fetch_task( + ident.project, + ident.domain, + ident.name, + ident.version, + ) + ft._python_interface = entity.python_interface + return ft + + def register_workflow( + self, + entity: WorkflowBase, + serialization_settings: typing.Optional[SerializationSettings] = None, + version: typing.Optional[str] = None, + default_launch_plan: typing.Optional[bool] = True, + options: typing.Optional[Options] = None, + ) -> FlyteWorkflow: + """ + Use this method to register a workflow. + :param version: version for the entity to be registered as + :param entity: The workflow to be registered + :param serialization_settings: The serialization settings to be used + :param default_launch_plan: This should be true if a default launch plan should be created for the workflow + :param options: Additional execution options that can be configured for the default launchplan + :return: + """ + ident = self._resolve_identifier(ResourceType.WORKFLOW, entity.name, version, serialization_settings) + if serialization_settings: + b = serialization_settings.new_builder() + b.project = ident.project + b.domain = ident.domain + b.version = ident.version + serialization_settings = b.build() + ident = self._serialize_and_register(entity, serialization_settings, version, options, default_launch_plan) + fwf = self.fetch_workflow(ident.project, ident.domain, ident.name, ident.version) + fwf._python_interface = entity.python_interface + return fwf + + def fast_package(self, root: os.PathLike, deref_symlinks: bool = True, output: str = None) -> (bytes, str): + """ + Packages the given paths into an installable zip and returns the md5_bytes and the URL of the uploaded location + :param root: path to the root of the package system that should be uploaded + :param output: output path. Optional, will default to a tempdir + :param deref_symlinks: if symlinks should be dereferenced. Defaults to True + :return: md5_bytes, url + """ + # Create a zip file containing all the entries. + zip_file = fast_package(root, output, deref_symlinks) + md5_bytes, _, _ = hash_file(pathlib.Path(zip_file)) + + # Upload zip file to Admin using FlyteRemote. + return self.upload_file(pathlib.Path(zip_file)) + + def upload_file( + self, + to_upload: pathlib.Path, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + ) -> typing.Tuple[bytes, str]: + """ + Function will use remote's client to hash and then upload the file using Admin's data proxy service. + + :param to_upload: Must be a single file + :param project: Project to upload under, if not supplied will use the remote's default + :param domain: Domain to upload under, if not specified will use the remote's default + :return: The uploaded location. + """ + if not to_upload.is_file(): + raise ValueError(f"{to_upload} is not a single file, upload arg must be a single file.") + md5_bytes, str_digest, _ = hash_file(to_upload) + logger.debug(f"Text hash of file to upload is {str_digest}") + + upload_location = self.client.get_upload_signed_url( + project=project or self.default_project, + domain=domain or self.default_domain, + content_md5=md5_bytes, + filename=to_upload.name, + ) + + extra_headers = self.get_extra_headers_for_protocol(upload_location.native_url) + encoded_md5 = b64encode(md5_bytes) + with open(str(to_upload), "+rb") as local_file: + content = local_file.read() + content_length = len(content) + headers = {"Content-Length": str(content_length), "Content-MD5": encoded_md5} + headers.update(extra_headers) + rsp = requests.put( + upload_location.signed_url, + data=content, + headers=headers, + verify=False + if self._config.platform.insecure_skip_verify is True + else self._config.platform.ca_cert_file_path, + ) + + # Check both HTTP 201 and 200, because some storage backends (e.g. Azure) return 201 instead of 200. + if rsp.status_code not in (requests.codes["OK"], requests.codes["created"]): + raise FlyteValueException( + rsp.status_code, + f"Request to send data {upload_location.signed_url} failed.\nResponse: {rsp.text}", + ) + + logger.debug(f"Uploading {to_upload} to {upload_location.signed_url} native url {upload_location.native_url}") + + return md5_bytes, upload_location.native_url + + @staticmethod + def _version_from_hash( + md5_bytes: bytes, + serialization_settings: SerializationSettings, + *additional_context: str, + ) -> str: + """ + The md5 version that we send to S3/GCS has to match the file contents exactly, + but we don't have to use it when registering with the Flyte backend. + To avoid changes in the For that add the hash of the compilation settings to hash of file + + :param md5_bytes: + :param serialization_settings: + :param additional_context: This is for additional context to factor into the version computation, + meant for objects (like Options for instance) that don't easily consistently stringify. + :return: + """ + from flytekit import __version__ + + additional_context = additional_context or [] + + h = hashlib.md5(md5_bytes) + h.update(bytes(serialization_settings.to_json(), "utf-8")) + h.update(bytes(__version__, "utf-8")) + + for s in additional_context: + h.update(bytes(s, "utf-8")) + + # Omit the character '=' from the version as that's essentially padding used by the base64 encoding + # and does not increase entropy of the hash while making it very inconvenient to copy-and-paste. + return base64.urlsafe_b64encode(h.digest()).decode("ascii").rstrip("=") + + def register_script( + self, + entity: typing.Union[WorkflowBase, PythonTask], + image_config: typing.Optional[ImageConfig] = None, + version: typing.Optional[str] = None, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + destination_dir: str = ".", + 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, + envs: typing.Optional[typing.Dict[str, str]] = None, + ) -> typing.Union[FlyteWorkflow, FlyteTask]: + """ + Use this method to register a workflow via script mode. + :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 + :param options: Additional execution options that can be configured for the default launchplan + :param source_path: The root of the project path + :param module_name: the name of the module + :param envs: Environment variables to be passed to the serialization + :return: + """ + if image_config is None: + image_config = ImageConfig.auto_default_image() + + 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, + image_config=image_config, + git_repo=_get_git_repo_url(source_path), + env=envs, + fast_serialization_settings=FastSerializationSettings( + enabled=True, + destination_dir=destination_dir, + distribution_location=upload_native_url, + ), + ) + + if version is None: + + def _get_image_names(entity: typing.Union[PythonAutoContainerTask, WorkflowBase]) -> typing.List[str]: + if isinstance(entity, PythonAutoContainerTask) and isinstance(entity.container_image, ImageSpec): + return [entity.container_image.image_name()] + if isinstance(entity, WorkflowBase): + image_names = [] + for n in entity.nodes: + image_names.extend(_get_image_names(n.flyte_entity)) + return image_names + return [] + + # The md5 version that we send to S3/GCS has to match the file contents exactly, + # but we don't have to use it when registering with the Flyte backend. + # For that add the hash of the compilation settings to hash of file + version = self._version_from_hash(md5_bytes, serialization_settings, *_get_image_names(entity)) + + if isinstance(entity, PythonTask): + return self.register_task(entity, serialization_settings, version) + return self.register_workflow(entity, serialization_settings, version, default_launch_plan, options) + + def register_launch_plan( + self, + entity: LaunchPlan, + version: str, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + options: typing.Optional[Options] = None, + ) -> FlyteLaunchPlan: + """ + Register a given launchplan, possibly applying overrides from the provided options. + :param entity: Launchplan to be registered + :param version: + :param project: Optionally provide a project, if not already provided in flyteremote constructor or a separate one + :param domain: Optionally provide a domain, if not already provided in FlyteRemote constructor or a separate one + :param options: + :return: + """ + ss = SerializationSettings(image_config=ImageConfig(), project=project, domain=domain, version=version) + + ident = self._resolve_identifier(ResourceType.LAUNCH_PLAN, entity.name, version, ss) + m = OrderedDict() + idl_lp = get_serializable_launch_plan(m, ss, entity, recurse_downstream=False, options=options) + try: + self.client.create_launch_plan(ident, idl_lp.spec) + except FlyteEntityAlreadyExistsException: + logger.debug("Launchplan already exists, ignoring") + flp = self.fetch_launch_plan(ident.project, ident.domain, ident.name, ident.version) + flp._python_interface = entity.python_interface + return flp + + #################### + # Execute Entities # + #################### + + def _execute( + self, + entity: typing.Union[FlyteTask, FlyteWorkflow, FlyteLaunchPlan], + inputs: typing.Dict[str, typing.Any], + project: str = None, + domain: str = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + options: typing.Optional[Options] = None, + wait: bool = False, + type_hints: typing.Optional[typing.Dict[str, typing.Type]] = None, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """Common method for execution across all entities. + + :param flyte_id: entity identifier + :param inputs: dictionary mapping argument names to values + :param project: project on which to execute the entity referenced by flyte_id + :param domain: domain on which to execute the entity referenced by flyte_id + :param execution_name: name of the execution + :param wait: if True, waits for execution to complete + :param type_hints: map of python types to inputs so that the TypeEngine knows how to convert the input values + into Flyte Literals. + :param overwrite_cache: Allows for all cached values of a workflow and its tasks to be overwritten + for a single execution. If enabled, all calculations are performed even if cached results would + be available, overwriting the stored data once execution finishes successfully. + :param envs: Environment variables to set for the execution. + :param tags: Tags to set for the execution. + :param cluster_pool: Specify cluster pool on which newly created execution should be placed. + :returns: :class:`~flytekit.remote.workflow_execution.FlyteWorkflowExecution` + """ + if execution_name is not None and execution_name_prefix is not None: + raise ValueError("Only one of execution_name and execution_name_prefix can be set, but got both set") + execution_name_prefix = execution_name_prefix + "-" if execution_name_prefix is not None else None + execution_name = execution_name or (execution_name_prefix or "f") + uuid.uuid4().hex[:19] + if not options: + options = Options() + if options.disable_notifications is not None: + if options.disable_notifications: + notifications = None + else: + notifications = NotificationList(options.notifications) + else: + notifications = NotificationList([]) + + type_hints = type_hints or {} + literal_map = {} + with self.remote_context() as ctx: + input_flyte_type_map = entity.interface.inputs + + for k, v in inputs.items(): + if input_flyte_type_map.get(k) is None: + raise user_exceptions.FlyteValueException( + k, f"The {entity.__class__.__name__} doesn't have this input key." + ) + if isinstance(v, Literal): + lit = v + elif isinstance(v, Artifact): + raise user_exceptions.FlyteValueException(v, "Running with an artifact object is not yet possible.") + else: + if k not in type_hints: + try: + type_hints[k] = TypeEngine.guess_python_type(input_flyte_type_map[k].type) + except ValueError: + logger.debug(f"Could not guess type for {input_flyte_type_map[k].type}, skipping...") + variable = entity.interface.inputs.get(k) + hint = type_hints[k] + self.file_access._get_upload_signed_url_fn = functools.partial( + self.client.get_upload_signed_url, + project=project or self.default_project, + domain=domain or self.default_domain, + ) + lit = TypeEngine.to_literal(ctx, v, hint, variable.type) + literal_map[k] = lit + + literal_inputs = literal_models.LiteralMap(literals=literal_map) + + try: + # Currently, this will only execute the flyte entity referenced by + # flyte_id in the same project and domain. However, it is possible to execute it in a different project + # and domain, which is specified in the first two arguments of client.create_execution. This is useful + # in the case that I want to use a flyte entity from e.g. project "A" but actually execute the entity on a + # different project "B". For now, this method doesn't support this use case. + exec_id = self.client.create_execution( + project or self.default_project, + domain or self.default_domain, + execution_name, + ExecutionSpec( + entity.id, + ExecutionMetadata( + ExecutionMetadata.ExecutionMode.MANUAL, + "placeholder", # Admin replaces this from oidc token if auth is enabled. + 0, + ), + overwrite_cache=overwrite_cache, + notifications=notifications, + disable_all=options.disable_notifications, + labels=options.labels, + annotations=options.annotations, + raw_output_data_config=options.raw_output_data_config, + auth_role=None, + max_parallelism=options.max_parallelism, + security_context=options.security_context, + envs=common_models.Envs(envs) if envs else None, + tags=tags, + cluster_assignment=ClusterAssignment(cluster_pool=cluster_pool) if cluster_pool else None, + ), + literal_inputs, + ) + except user_exceptions.FlyteEntityAlreadyExistsException: + logger.warning( + f"Execution with Execution ID {execution_name} already exists. " + f"Assuming this is the same execution, returning!" + ) + exec_id = WorkflowExecutionIdentifier( + project=project or self.default_project, domain=domain or self.default_domain, name=execution_name + ) + execution = FlyteWorkflowExecution.promote_from_model(self.client.get_execution(exec_id)) + + if wait: + return self.wait(execution) + return execution + + def _resolve_identifier_kwargs( + self, + entity: typing.Any, + project: str, + domain: str, + name: str, + version: str, + ) -> ResolvedIdentifiers: + """ + Resolves the identifier attributes based on user input, falling back on the default project/domain and + auto-generated version, and ultimately the entity project/domain if entity is a remote flyte entity. + """ + ident = ResolvedIdentifiers( + project=project or self.default_project, + domain=domain or self.default_domain, + name=name or entity.name, + version=version, + ) + if not (ident.project and ident.domain and ident.name): + raise ValueError( + f"Cannot launch an execution with missing project/domain/name {ident} for entity type {type(entity)}." + f" Specify them in the execute method or when initializing FlyteRemote" + ) + return ident + + def execute( + self, + entity: typing.Union[FlyteTask, FlyteLaunchPlan, FlyteWorkflow, PythonTask, WorkflowBase, LaunchPlan], + inputs: typing.Dict[str, typing.Any], + project: str = None, + domain: str = None, + name: str = None, + version: str = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + image_config: typing.Optional[ImageConfig] = None, + options: typing.Optional[Options] = None, + wait: bool = False, + type_hints: typing.Optional[typing.Dict[str, typing.Type]] = None, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """ + Execute a task, workflow, or launchplan, either something that's been declared locally, or a fetched entity. + + This method supports: + - ``Flyte{Task, Workflow, LaunchPlan}`` remote module objects. + - ``@task``-decorated functions and ``TaskTemplate`` tasks. + - ``@workflow``-decorated functions. + - ``LaunchPlan`` objects. + + For local entities, this code will attempt to find the entity first, and if missing, will compile and register + the object. + + Not all arguments are relevant in all circumstances. For example, there's no reason to use the serialization + settings for entities that have already been registered on Admin. + + :param options: + :param entity: entity to execute + :param inputs: dictionary mapping argument names to values + :param project: execute entity in this project. If entity doesn't exist in the project, register the entity + first before executing. + :param domain: execute entity in this domain. If entity doesn't exist in the domain, register the entity + first before executing. + :param name: execute entity using this name. If not None, use this value instead of ``entity.name`` + :param version: execute entity using this version. If None, uses auto-generated value. + :param execution_name: name of the execution. If None, uses auto-generated value. + :param image_config: + :param wait: if True, waits for execution to complete + :param type_hints: Python types to be passed to the TypeEngine so that it knows how to properly convert the + input values for the execution into Flyte literals. If missing, will default to first guessing the type + using the type engine, and then to ``type(v)``. Providing the correct Python types is particularly important + if the inputs are containers like lists or maps, or if the Python type is one of the more complex Flyte + provided classes (like a StructuredDataset that's annotated with columns). + :param overwrite_cache: Allows for all cached values of a workflow and its tasks to be overwritten + for a single execution. If enabled, all calculations are performed even if cached results would + be available, overwriting the stored data once execution finishes successfully. + :param envs: Environment variables to be set for the execution. + :param tags: Tags to be set for the execution. + :param cluster_pool: Specify cluster pool on which newly created execution should be placed. + + .. note: + + The ``name`` and ``version`` arguments do not apply to ``FlyteTask``, ``FlyteLaunchPlan``, and + ``FlyteWorkflow`` entity inputs. These values are determined by referencing the entity identifier values. + """ + if entity.python_interface: + type_hints = type_hints or entity.python_interface.inputs + if isinstance(entity, FlyteTask) or isinstance(entity, FlyteLaunchPlan): + return self.execute_remote_task_lp( + entity=entity, + inputs=inputs, + project=project, + domain=domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + options=options, + wait=wait, + type_hints=type_hints, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + if isinstance(entity, FlyteWorkflow): + return self.execute_remote_wf( + entity=entity, + inputs=inputs, + project=project, + domain=domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + options=options, + wait=wait, + type_hints=type_hints, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + if isinstance(entity, PythonTask): + return self.execute_local_task( + entity=entity, + inputs=inputs, + project=project, + domain=domain, + name=name, + version=version, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + image_config=image_config, + wait=wait, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + if isinstance(entity, WorkflowBase): + return self.execute_local_workflow( + entity=entity, + inputs=inputs, + project=project, + domain=domain, + name=name, + version=version, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + image_config=image_config, + options=options, + wait=wait, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + if isinstance(entity, LaunchPlan): + return self.execute_local_launch_plan( + entity=entity, + inputs=inputs, + version=version, + project=project, + domain=domain, + name=name, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + options=options, + wait=wait, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + raise NotImplementedError(f"entity type {type(entity)} not recognized for execution") + + # Flyte Remote Entities + # --------------------- + + def execute_remote_task_lp( + self, + entity: typing.Union[FlyteTask, FlyteLaunchPlan], + inputs: typing.Dict[str, typing.Any], + project: str = None, + domain: str = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + options: typing.Optional[Options] = None, + wait: bool = False, + type_hints: typing.Optional[typing.Dict[str, typing.Type]] = None, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """Execute a FlyteTask, or FlyteLaunchplan. + + NOTE: the name and version arguments are currently not used and only there consistency in the function signature + """ + return self._execute( + entity, + inputs, + project=project, + domain=domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + wait=wait, + options=options, + type_hints=type_hints, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + + def execute_remote_wf( + self, + entity: FlyteWorkflow, + inputs: typing.Dict[str, typing.Any], + project: str = None, + domain: str = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + options: typing.Optional[Options] = None, + wait: bool = False, + type_hints: typing.Optional[typing.Dict[str, typing.Type]] = None, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """Execute a FlyteWorkflow. + + NOTE: the name and version arguments are currently not used and only there consistency in the function signature + """ + launch_plan = self.fetch_launch_plan(entity.id.project, entity.id.domain, entity.id.name, entity.id.version) + return self.execute_remote_task_lp( + launch_plan, + inputs, + project=project, + domain=domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + options=options, + wait=wait, + type_hints=type_hints, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + + # Flytekit Entities + # ----------------- + + def execute_local_task( + self, + entity: PythonTask, + inputs: typing.Dict[str, typing.Any], + project: str = None, + domain: str = None, + name: str = None, + version: str = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + image_config: typing.Optional[ImageConfig] = None, + wait: bool = False, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """ + Execute a @task-decorated function or TaskTemplate task. + + :param entity: local task entity. + :param inputs: register the task, which requires compiling the task, before running it. + :param project: The execution project, will default to the Remote's default project. + :param domain: The execution domain, will default to the Remote's default domain. + :param name: specific name of the task to run. + :param version: specific version of the task to run. + :param execution_name: If provided, will use this name for the execution. + :param image_config: If provided, will use this image config in the pod. + :param wait: If True, will wait for the execution to complete before returning. + :param overwrite_cache: If True, will overwrite the cache. + :param envs: Environment variables to set for the execution. + :param tags: Tags to set for the execution. + :param cluster_pool: Specify cluster pool on which newly created execution should be placed. + :return: FlyteWorkflowExecution object. + """ + resolved_identifiers = self._resolve_identifier_kwargs(entity, project, domain, name, version) + resolved_identifiers_dict = asdict(resolved_identifiers) + try: + flyte_task: FlyteTask = self.fetch_task(**resolved_identifiers_dict) + except FlyteEntityNotExistException: + if isinstance(entity, PythonAutoContainerTask): + if not image_config: + raise ValueError(f"PythonTask {entity.name} not already registered, but image_config missing") + ss = SerializationSettings( + image_config=image_config, + project=project or self.default_project, + domain=domain or self._default_domain, + version=version, + ) + flyte_task: FlyteTask = self.register_task(entity, ss) + + return self.execute( + flyte_task, + inputs, + project=resolved_identifiers.project, + domain=resolved_identifiers.domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + wait=wait, + type_hints=entity.python_interface.inputs, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + + def execute_local_workflow( + self, + entity: WorkflowBase, + inputs: typing.Dict[str, typing.Any], + project: str = None, + domain: str = None, + name: str = None, + version: str = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + image_config: typing.Optional[ImageConfig] = None, + options: typing.Optional[Options] = None, + wait: bool = False, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """ + Execute an @workflow decorated function. + :param entity: + :param inputs: + :param project: + :param domain: + :param name: + :param version: + :param execution_name: + :param image_config: + :param options: + :param wait: + :param overwrite_cache: + :param envs: + :param tags: + :param cluster_pool: + :return: + """ + resolved_identifiers = self._resolve_identifier_kwargs(entity, project, domain, name, version) + resolved_identifiers_dict = asdict(resolved_identifiers) + ss = SerializationSettings( + image_config=image_config, + project=resolved_identifiers.project, + domain=resolved_identifiers.domain, + version=resolved_identifiers.version, + ) + try: + # Just fetch to see if it already exists + # todo: Add logic to check that the fetched workflow is functionally equivalent. + self.fetch_workflow(**resolved_identifiers_dict) + except FlyteEntityNotExistException: + logger.info("Registering workflow because it wasn't found in Flyte Admin.") + if not image_config: + raise ValueError("Need image config since we are registering") + self.register_workflow(entity, ss, version=version, options=options) + + try: + flyte_lp = self.fetch_launch_plan(**resolved_identifiers_dict) + except FlyteEntityNotExistException: + logger.info("Try to register default launch plan because it wasn't found in Flyte Admin!") + default_lp = LaunchPlan.get_default_launch_plan(self.context, entity) + self.register_launch_plan( + default_lp, + project=resolved_identifiers.project, + domain=resolved_identifiers.domain, + version=version, + options=options, + ) + flyte_lp = self.fetch_launch_plan(**resolved_identifiers_dict) + + return self.execute( + flyte_lp, + inputs, + project=project, + domain=domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + wait=wait, + options=options, + type_hints=entity.python_interface.inputs, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + + def execute_local_launch_plan( + self, + entity: LaunchPlan, + inputs: typing.Dict[str, typing.Any], + version: str, + project: typing.Optional[str] = None, + domain: typing.Optional[str] = None, + name: typing.Optional[str] = None, + execution_name: typing.Optional[str] = None, + execution_name_prefix: typing.Optional[str] = None, + options: typing.Optional[Options] = None, + wait: bool = False, + overwrite_cache: typing.Optional[bool] = None, + envs: typing.Optional[typing.Dict[str, str]] = None, + tags: typing.Optional[typing.List[str]] = None, + cluster_pool: typing.Optional[str] = None, + ) -> FlyteWorkflowExecution: + """ + + :param entity: The locally defined launch plan object + :param inputs: Inputs to be passed into the execution as a dict with Python native values. + :param version: The version to look up/register the launch plan (if not already exists) + :param project: The same as version, but will default to the Remote object's project + :param domain: The same as version, but will default to the Remote object's domain + :param name: The same as version, but will default to the entity's name + :param execution_name: If specified, will be used as the execution name instead of randomly generating. + :param options: Options to be passed into the execution. + :param wait: If True, will wait for the execution to complete before returning. + :param overwrite_cache: If True, will overwrite the cache. + :param envs: Environment variables to be passed into the execution. + :param tags: Tags to be passed into the execution. + :param cluster_pool: Specify cluster pool on which newly created execution should be placed. + :return: FlyteWorkflowExecution object + """ + resolved_identifiers = self._resolve_identifier_kwargs(entity, project, domain, name, version) + resolved_identifiers_dict = asdict(resolved_identifiers) + project = resolved_identifiers.project + domain = resolved_identifiers.domain + try: + flyte_launchplan: FlyteLaunchPlan = self.fetch_launch_plan(**resolved_identifiers_dict) + except FlyteEntityNotExistException: + flyte_launchplan: FlyteLaunchPlan = self.register_launch_plan( + entity, + version=version, + project=project, + domain=domain, + ) + return self.execute_remote_task_lp( + flyte_launchplan, + inputs, + project=project, + domain=domain, + execution_name=execution_name, + execution_name_prefix=execution_name_prefix, + options=options, + wait=wait, + type_hints=entity.python_interface.inputs, + overwrite_cache=overwrite_cache, + envs=envs, + tags=tags, + cluster_pool=cluster_pool, + ) + + ################################### + # Wait for Executions to Complete # + ################################### + + def wait( + self, + execution: FlyteWorkflowExecution, + timeout: typing.Optional[timedelta] = None, + poll_interval: typing.Optional[timedelta] = None, + sync_nodes: bool = True, + ) -> FlyteWorkflowExecution: + """Wait for an execution to finish. + + :param execution: execution object to wait on + :param timeout: maximum amount of time to wait + :param poll_interval: sync workflow execution at this interval + :param sync_nodes: passed along to the sync call for the workflow execution + """ + poll_interval = poll_interval or timedelta(seconds=30) + time_to_give_up = datetime.max if timeout is None else datetime.utcnow() + timeout + + while datetime.utcnow() < time_to_give_up: + execution = self.sync_execution(execution, sync_nodes=sync_nodes) + if execution.is_done: + return execution + time.sleep(poll_interval.total_seconds()) + + raise user_exceptions.FlyteTimeout(f"Execution {self} did not complete before timeout.") + + ######################## + # Sync Execution State # + ######################## + + def sync( + self, + execution: FlyteWorkflowExecution, + entity_definition: typing.Union[FlyteWorkflow, FlyteTask] = None, + sync_nodes: bool = False, + ) -> FlyteWorkflowExecution: + """ + This function was previously a singledispatchmethod. We've removed that but this function remains + so that we don't break people. + + :param execution: + :param entity_definition: + :param sync_nodes: By default sync will fetch data on all underlying node executions (recursively, + so subworkflows will also get picked up). Set this to False in order to prevent that (which + will make this call faster). + :return: Returns the same execution object, but with additional information pulled in. + """ + if not isinstance(execution, FlyteWorkflowExecution): + raise ValueError(f"remote.sync should only be called on workflow executions, got {type(execution)}") + return self.sync_execution(execution, entity_definition, sync_nodes) + + def sync_execution( + self, + execution: FlyteWorkflowExecution, + entity_definition: typing.Union[FlyteWorkflow, FlyteTask] = None, + sync_nodes: bool = False, + ) -> FlyteWorkflowExecution: + """ + Sync a FlyteWorkflowExecution object with its corresponding remote state. + """ + if entity_definition is not None: + raise ValueError("Entity definition arguments aren't supported when syncing workflow executions") + + # Update closure, and then data, because we don't want the execution to finish between when we get the data, + # and then for the closure to have is_done to be true. + execution._closure = self.client.get_execution(execution.id).closure + execution_data = self.client.get_execution_data(execution.id) + lp_id = execution.spec.launch_plan + underlying_node_executions = [] + if sync_nodes: + underlying_node_executions = [ + FlyteNodeExecution.promote_from_model(n) for n in iterate_node_executions(self.client, execution.id) + ] + + # This condition is only true for single-task executions + if execution.spec.launch_plan.resource_type == ResourceType.TASK: + flyte_entity = self.fetch_task(lp_id.project, lp_id.domain, lp_id.name, lp_id.version) + node_interface = flyte_entity.interface + if sync_nodes: + # Need to construct the mapping. There should've been returned exactly three nodes, a start, + # an end, and a task node. + task_node_exec = [ + x + for x in filter( + lambda x: x.id.node_id != constants.START_NODE_ID and x.id.node_id != constants.END_NODE_ID, + underlying_node_executions, + ) + ] + # We need to manually make a map of the nodes since there is none for single task executions + # Assume the first one is the only one. + node_mapping = ( + { + task_node_exec[0].id.node_id: FlyteNode( + id=flyte_entity.id, + upstream_nodes=[], + bindings=[], + metadata=NodeMetadata(name=""), + task_node=FlyteTaskNode(flyte_entity), + ) + } + if len(task_node_exec) >= 1 + else {} # This is for the case where node executions haven't appeared yet + ) + # This is the default case, an execution of a normal workflow through a launch plan + else: + fetched_lp = self.fetch_launch_plan(lp_id.project, lp_id.domain, lp_id.name, lp_id.version) + node_interface = fetched_lp.flyte_workflow.interface + execution._flyte_workflow = fetched_lp.flyte_workflow + node_mapping = fetched_lp.flyte_workflow._node_map + + # update node executions (if requested), and inputs/outputs + if sync_nodes: + node_execs = {} + for n in underlying_node_executions: + node_execs[n.id.node_id] = self.sync_node_execution(n, node_mapping) # noqa + execution._node_executions = node_execs + return self._assign_inputs_and_outputs(execution, execution_data, node_interface) + + def sync_node_execution( + self, + execution: FlyteNodeExecution, + node_mapping: typing.Dict[str, FlyteNode], + ) -> FlyteNodeExecution: + """ + Get data backing a node execution. These FlyteNodeExecution objects should've come from Admin with the model + fields already populated correctly. For purposes of the remote experience, we'd like to supplement the object + with some additional fields: + - inputs/outputs + - task/workflow executions, and/or underlying node executions in the case of parent nodes + - TypedInterface (remote wrapper type) + + A node can have several different types of executions behind it. That is, the node could've run (perhaps + multiple times because of retries): + - A task + - A static subworkflow + - A dynamic subworkflow (which in turn may have run additional tasks, subwfs, and/or launch plans) + - A launch plan + + The data model is complicated, so ascertaining which of these happened is a bit tricky. That logic is + encapsulated in this function. + """ + # For single task execution - the metadata spec node id is missing. In these cases, revert to regular node id + node_id = execution.metadata.spec_node_id + # This case supports single-task execution compiled workflows. + if node_id and node_id not in node_mapping and execution.id.node_id in node_mapping: + node_id = execution.id.node_id + logger.debug( + f"Using node execution ID {node_id} instead of spec node id " + f"{execution.metadata.spec_node_id}, single-task execution likely." + ) + # This case supports single-task execution compiled workflows with older versions of admin/propeller + if not node_id: + node_id = execution.id.node_id + logger.debug(f"No metadata spec_node_id found, using {node_id}") + + # First see if it's a dummy node, if it is, we just skip it. + if constants.START_NODE_ID in node_id or constants.END_NODE_ID in node_id: + return execution + + # Look for the Node object in the mapping supplied + if node_id in node_mapping: + execution._node = node_mapping[node_id] + else: + raise Exception(f"Missing node from mapping: {node_id}") + + # Get the node execution data + node_execution_get_data_response = self.client.get_node_execution_data(execution.id) + + # Calling a launch plan directly case + # If a node ran a launch plan directly (i.e. not through a dynamic task or anything) then + # the closure should have a workflow_node_metadata populated with the launched execution id. + # The parent node flag should not be populated here + # This is the simplest case + if not execution.metadata.is_parent_node and execution.closure.workflow_node_metadata: + launched_exec_id = execution.closure.workflow_node_metadata.execution_id + # This is a recursive call, basically going through the same process that brought us here in the first + # place, but on the launched execution. + launched_exec = self.fetch_execution( + project=launched_exec_id.project, domain=launched_exec_id.domain, name=launched_exec_id.name + ) + self.sync_execution(launched_exec) + if launched_exec.is_done: + # The synced underlying execution should've had these populated. + execution._inputs = launched_exec.inputs + execution._outputs = launched_exec.outputs + execution._workflow_executions.append(launched_exec) + execution._interface = launched_exec._flyte_workflow.interface + return execution + + # If a node ran a static subworkflow or a dynamic subworkflow then the parent flag will be set. + if execution.metadata.is_parent_node: + # We'll need to query child node executions regardless since this is a parent node + child_node_executions = iterate_node_executions( + self.client, + workflow_execution_identifier=execution.id.execution_id, + unique_parent_id=execution.id.node_id, + ) + child_node_executions = [x for x in child_node_executions] + + # If this was a dynamic task, then there should be a CompiledWorkflowClosure inside the + # NodeExecutionGetDataResponse + if node_execution_get_data_response.dynamic_workflow is not None: + compiled_wf = node_execution_get_data_response.dynamic_workflow.compiled_workflow + node_launch_plans = {} + # TODO: Inspect branch nodes for launch plans + for node in FlyteWorkflow.get_non_system_nodes(compiled_wf.primary.template.nodes): + if ( + node.workflow_node is not None + and node.workflow_node.launchplan_ref is not None + and node.workflow_node.launchplan_ref not in node_launch_plans + ): + node_launch_plans[node.workflow_node.launchplan_ref] = self.client.get_launch_plan( + node.workflow_node.launchplan_ref + ).spec + + dynamic_flyte_wf = FlyteWorkflow.promote_from_closure(compiled_wf, node_launch_plans) + execution._underlying_node_executions = [ + self.sync_node_execution(FlyteNodeExecution.promote_from_model(cne), dynamic_flyte_wf._node_map) + for cne in child_node_executions + ] + execution._task_executions = [ + node_exes.task_executions for node_exes in execution.subworkflow_node_executions.values() + ] + + execution._interface = dynamic_flyte_wf.interface + + # Handle the case where it's a static subworkflow + elif isinstance(execution._node.flyte_entity, FlyteWorkflow): + sub_flyte_workflow = execution._node.flyte_entity + sub_node_mapping = {n.id: n for n in sub_flyte_workflow.flyte_nodes} + execution._underlying_node_executions = [ + self.sync_node_execution(FlyteNodeExecution.promote_from_model(cne), sub_node_mapping) + for cne in child_node_executions + ] + execution._interface = sub_flyte_workflow.interface + + # Handle the case where it's a branch node + elif execution._node.branch_node is not None: + logger.info( + "Skipping branch node execution for now - branch nodes will " + "not have inputs and outputs filled in" + ) + return execution + else: + logger.error(f"NE {execution} undeterminable, {type(execution._node)}, {execution._node}") + raise Exception(f"Node execution undeterminable, entity has type {type(execution._node)}") + + # Handle the case for gate nodes + elif execution._node.gate_node is not None: + logger.info("Skipping gate node execution for now - gate nodes don't have inputs and outputs filled in") + return execution + + # This is the plain ol' task execution case + else: + execution._task_executions = [ + self.sync_task_execution( + FlyteTaskExecution.promote_from_model(t), node_mapping[node_id].task_node.flyte_task + ) + for t in iterate_task_executions(self.client, execution.id) + ] + execution._interface = execution._node.flyte_entity.interface + + self._assign_inputs_and_outputs( + execution, + node_execution_get_data_response, + execution.interface, + ) + + return execution + + def sync_task_execution( + self, execution: FlyteTaskExecution, entity_definition: typing.Optional[FlyteTask] = None + ) -> FlyteTaskExecution: + """Sync a FlyteTaskExecution object with its corresponding remote state.""" + execution._closure = self.client.get_task_execution(execution.id).closure + execution_data = self.client.get_task_execution_data(execution.id) + task_id = execution.id.task_id + if entity_definition is None: + entity_definition = self.fetch_task(task_id.project, task_id.domain, task_id.name, task_id.version) + return self._assign_inputs_and_outputs(execution, execution_data, entity_definition.interface) + + ############################# + # Terminate Execution State # + ############################# + + def terminate(self, execution: FlyteWorkflowExecution, cause: str): + """Terminate a workflow execution. + + :param execution: workflow execution to terminate + :param cause: reason for termination + """ + self.client.terminate_execution(execution.id, cause) + + ################## + # Helper Methods # + ################## + + def _assign_inputs_and_outputs( + self, + execution: typing.Union[FlyteWorkflowExecution, FlyteNodeExecution, FlyteTaskExecution], + execution_data, + interface: TypedInterface, + ): + """Helper for assigning synced inputs and outputs to an execution object.""" + input_literal_map = self._get_input_literal_map(execution_data) + execution._inputs = LiteralsResolver(input_literal_map.literals, interface.inputs, self.context) + + if execution.is_done and not execution.error: + output_literal_map = self._get_output_literal_map(execution_data) + execution._outputs = LiteralsResolver(output_literal_map.literals, interface.outputs, self.context) + return execution + + def _get_input_literal_map(self, execution_data: ExecutionDataResponse) -> literal_models.LiteralMap: + # Inputs are returned inline unless they are too big, in which case a url blob pointing to them is returned. + if bool(execution_data.full_inputs.literals): + return execution_data.full_inputs + elif execution_data.inputs.bytes > 0: + with self.remote_context() as ctx: + tmp_name = os.path.join(ctx.file_access.local_sandbox_dir, "inputs.pb") + ctx.file_access.get_data(execution_data.inputs.url, tmp_name) + return literal_models.LiteralMap.from_flyte_idl( + utils.load_proto_from_file(literals_pb2.LiteralMap, tmp_name) + ) + return literal_models.LiteralMap({}) + + def _get_output_literal_map(self, execution_data: ExecutionDataResponse) -> literal_models.LiteralMap: + # Outputs are returned inline unless they are too big, in which case a url blob pointing to them is returned. + if bool(execution_data.full_outputs.literals): + return execution_data.full_outputs + elif execution_data.outputs.bytes > 0: + with self.remote_context() as ctx: + tmp_name = os.path.join(ctx.file_access.local_sandbox_dir, "outputs.pb") + ctx.file_access.get_data(execution_data.outputs.url, tmp_name) + return literal_models.LiteralMap.from_flyte_idl( + utils.load_proto_from_file(literals_pb2.LiteralMap, tmp_name) + ) + return literal_models.LiteralMap({}) + + def generate_console_http_domain(self) -> str: + """ + This should generate the domain where console is hosted. + + :return: + """ + # If the console endpoint is explicitly set, return it, else derive it from the admin config + if self.config.platform.console_endpoint: + return self.config.platform.console_endpoint + protocol = "http" if self.config.platform.insecure else "https" + endpoint = self.config.platform.endpoint + # N.B.: this assumes that in case we have an identical configuration as the sandbox default config we are running single binary. The intent here is + # to ensure that the urls produced in the getting started guide point to the correct place. + if self.config.platform == Config.for_sandbox().platform: + endpoint = "localhost:30080" + return protocol + f"://{endpoint}" + + def generate_console_url( + self, + entity: typing.Union[ + FlyteWorkflowExecution, FlyteNodeExecution, FlyteTaskExecution, FlyteWorkflow, FlyteTask, FlyteLaunchPlan + ], + ): + """ + Generate a Flyteconsole URL for the given Flyte remote endpoint. + This will automatically determine if this is an execution or an entity and change the type automatically + """ + if isinstance(entity, (FlyteWorkflowExecution, FlyteNodeExecution, FlyteTaskExecution)): + return f"{self.generate_console_http_domain()}/console/projects/{entity.id.project}/domains/{entity.id.domain}/executions/{entity.id.name}" # noqa + + if not isinstance(entity, (FlyteWorkflow, FlyteTask, FlyteLaunchPlan)): + raise ValueError(f"Only remote entities can be looked at in the console, got type {type(entity)}") + rt = "workflow" + if entity.id.resource_type == ResourceType.TASK: + rt = "task" + elif entity.id.resource_type == ResourceType.LAUNCH_PLAN: + rt = "launch_plan" + return f"{self.generate_console_http_domain()}/console/projects/{entity.id.project}/domains/{entity.id.domain}/{rt}/{entity.name}/version/{entity.id.version}" # noqa + + def launch_backfill( + self, + project: str, + domain: str, + from_date: datetime, + to_date: datetime, + launchplan: str, + launchplan_version: str = None, + execution_name: str = None, + version: str = None, + dry_run: bool = False, + execute: bool = True, + parallel: bool = False, + failure_policy: typing.Optional[WorkflowFailurePolicy] = None, + ) -> typing.Optional[FlyteWorkflowExecution, FlyteWorkflow, WorkflowBase]: + """ + Creates and launches a backfill workflow for the given launchplan. If launchplan version is not specified, + then the latest launchplan is retrieved. + The from_date is exclusive and end_date is inclusive and backfill run for all instances in between. :: + -> (start_date - exclusive, end_date inclusive) + + If dry_run is specified, the workflow is created and returned. + If execute==False is specified then the workflow is created and registered. + In the last case, the workflow is created, registered and executed. + + The `parallel` flag can be used to generate a workflow where all launchplans can be run in parallel. Default + is that execute backfill is run sequentially + + :param project: str project name + :param domain: str domain name + :param from_date: datetime generate a backfill starting at this datetime (exclusive) + :param to_date: datetime generate a backfill ending at this datetime (inclusive) + :param launchplan: str launchplan name in the flyte backend + :param launchplan_version: str (optional) version for the launchplan. If not specified the most recent will be retrieved + :param execution_name: str (optional) the generated execution will be named so. this can help in ensuring idempotency + :param version: str (optional) version to be used for the newly created workflow. + :param dry_run: bool do not register or execute the workflow + :param execute: bool Register and execute the wwkflow. + :param parallel: if the backfill should be run in parallel. False (default) will run each bacfill sequentially. + :param failure_policy: WorkflowFailurePolicy (optional) to be used for the newly created workflow. This can + control failure behavior - whether to continue on failure or stop immediately on failure + :return: In case of dry-run, return WorkflowBase, else if no_execute return FlyteWorkflow else in the default + case return a FlyteWorkflowExecution + """ + lp = self.fetch_launch_plan(project=project, domain=domain, name=launchplan, version=launchplan_version) + wf, start, end = create_backfill_workflow( + start_date=from_date, end_date=to_date, for_lp=lp, parallel=parallel, failure_policy=failure_policy + ) + if dry_run: + logger.warning("Dry Run enabled. Workflow will not be registered and or executed.") + return wf + + unique_fingerprint = f"{start}-{end}-{launchplan}-{launchplan_version}" + h = hashlib.md5() + h.update(unique_fingerprint.encode("utf-8")) + unique_fingerprint_encoded = base64.urlsafe_b64encode(h.digest()).decode("ascii") + if not version: + version = unique_fingerprint_encoded + ss = SerializationSettings( + image_config=ImageConfig.auto(), + project=project, + domain=domain, + version=version, + ) + remote_wf = self.register_workflow(wf, serialization_settings=ss) + + if not execute: + return remote_wf + + return self.execute(remote_wf, inputs={}, project=project, domain=domain, execution_name=execution_name) + + @staticmethod + def get_extra_headers_for_protocol(native_url): + if native_url.startswith("abfs://"): + return {"x-ms-blob-type": "BlockBlob"} + return {} + + def activate_launchplan(self, ident: Identifier): + """ + Given a launchplan, activate it, all previous versions are deactivated. + """ + self.client.update_launch_plan(id=ident, state=LaunchPlanState.ACTIVE) + + def download( + self, data: typing.Union[LiteralsResolver, Literal, LiteralMap], download_to: str, recursive: bool = True + ): + """ + Download the data to the specified location. If the data is a LiteralsResolver, LiteralMap and if recursive is + specified, then all file like objects will be recursively downloaded (e.g. FlyteFile/Dir (blob), + StructuredDataset etc). + + Note: That it will use your sessions credentials to access the remote location. For sandbox, this should be + automatically configured, assuming you are running sandbox locally. For other environments, you will need to + configure your credentials appropriately. + + :param data: data to be downloaded + :param download_to: location to download to (str) that should be a valid path + :param recursive: if the data is a LiteralsResolver or LiteralMap, then this flag will recursively download + """ + download_to = pathlib.Path(download_to) + if isinstance(data, Literal): + download_literal(self.file_access, "data", data, download_to) + else: + if not recursive: + raise click.UsageError("Please specify --recursive to download all variables in a literal map.") + if isinstance(data, LiteralsResolver): + lm = data.literals + else: + lm = data + for var, literal in lm.items(): + download_literal(self.file_access, var, literal, download_to) diff --git a/flytekit/flytekit/remote/remote_callable.py b/flytekit/flytekit/remote/remote_callable.py new file mode 100644 index 0000000000..5b177bf7c4 --- /dev/null +++ b/flytekit/flytekit/remote/remote_callable.py @@ -0,0 +1,75 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Tuple, Type, Union + +from flytekit.core.context_manager import BranchEvalMode, ExecutionState, FlyteContext +from flytekit.core.promise import Promise, VoidPromise, create_and_link_node_from_remote, extract_obj_name +from flytekit.exceptions import user as user_exceptions +from flytekit.loggers import logger +from flytekit.models.core.workflow import NodeMetadata + + +class RemoteEntity(ABC): + def __init__(self, *args, **kwargs): + # In cases where we make a FlyteTask/Workflow/LaunchPlan from a locally created Python object (i.e. an @task + # or an @workflow decorated function), we actually have the Python interface, so + self._python_interface: Optional[Dict[str, Type]] = None + + super().__init__(*args, **kwargs) + + @property + @abstractmethod + def name(self) -> str: + ... + + def construct_node_metadata(self) -> NodeMetadata: + """ + Used when constructing the node that encapsulates this task as part of a broader workflow definition. + """ + return NodeMetadata( + name=extract_obj_name(self.name), + ) + + def compile(self, ctx: FlyteContext, *args, **kwargs): + return create_and_link_node_from_remote(ctx, entity=self, **kwargs) # noqa + + def __call__(self, *args, **kwargs): + # When a Task is () aka __called__, there are three things we may do: + # a. Plain execution Mode - just run the execute function. If not overridden, we should raise an exception + # b. Compilation Mode - this happens when the function is called as part of a workflow (potentially + # dynamic task). Produce promise objects and create a node. + # c. Workflow Execution Mode - when a workflow is being run locally. Even though workflows are functions + # and everything should be able to be passed through naturally, we'll want to wrap output values of the + # function into objects, so that potential .with_cpu or other ancillary functions can be attached to do + # nothing. Subsequent tasks will have to know how to unwrap these. If by chance a non-Flyte task uses a + # task output as an input, things probably will fail pretty obviously. + # Since this is a reference entity, it still needs to be mocked otherwise an exception will be raised. + if len(args) > 0: + raise user_exceptions.FlyteAssertion( + f"Cannot call remotely fetched entity with args - detected {len(args)} positional args {args}" + ) + + ctx = FlyteContext.current_context() + if ctx.compilation_state is not None and ctx.compilation_state.mode == 1: + return self.compile(ctx, *args, **kwargs) + elif ( + ctx.execution_state is not None and ctx.execution_state.mode == ExecutionState.Mode.LOCAL_WORKFLOW_EXECUTION + ): + if ctx.execution_state.branch_eval_mode == BranchEvalMode.BRANCH_SKIPPED: + return + return self.local_execute(ctx, **kwargs) + else: + logger.debug("Fetched entity, running raw execute.") + return self.execute(**kwargs) + + def local_execute(self, ctx: FlyteContext, **kwargs) -> Optional[Union[Tuple[Promise], Promise, VoidPromise]]: + return self.execute(**kwargs) + + def local_execution_mode(self) -> ExecutionState.Mode: + return ExecutionState.Mode.LOCAL_TASK_EXECUTION + + def execute(self, **kwargs) -> Any: + raise AssertionError(f"Remotely fetched entities cannot be run locally. Please mock the {self.name}.execute.") + + @property + def python_interface(self) -> Optional[Dict[str, Type]]: + return self._python_interface diff --git a/flytekit/flytekit/remote/remote_fs.py b/flytekit/flytekit/remote/remote_fs.py new file mode 100644 index 0000000000..4eb9d8ebc6 --- /dev/null +++ b/flytekit/flytekit/remote/remote_fs.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import base64 +import hashlib +import os +import pathlib +import random +import threading +import typing +from base64 import b64encode +from uuid import UUID + +import fsspec +import requests +from flyteidl.service.dataproxy_pb2 import CreateUploadLocationResponse +from fsspec.callbacks import NoOpCallback +from fsspec.implementations.http import HTTPFileSystem +from fsspec.utils import get_protocol + +from flytekit.loggers import logger +from flytekit.tools.script_mode import hash_file + +if typing.TYPE_CHECKING: + from flytekit.remote.remote import FlyteRemote + +_DEFAULT_CALLBACK = NoOpCallback() +_PREFIX_KEY = "upload_prefix" +_HASHES_KEY = "hashes" +# This file system is not really a filesystem, so users aren't really able to specify the remote path, +# at least not yet. +REMOTE_PLACEHOLDER = "flyte://data" + +HashStructure = typing.Dict[str, typing.Tuple[bytes, int]] + + +class FlytePathResolver: + protocol = "flyte://" + _flyte_path_to_remote_map: typing.Dict[str, str] = {} + _lock = threading.Lock() + + @classmethod + def resolve_remote_path(cls, flyte_uri: str) -> typing.Optional[str]: + """ + Given a flyte uri, return the remote path if it exists or was created in current session, otherwise return None + """ + with cls._lock: + if flyte_uri in cls._flyte_path_to_remote_map: + return cls._flyte_path_to_remote_map[flyte_uri] + return None + + @classmethod + def add_mapping(cls, flyte_uri: str, remote_path: str): + """ + Thread safe method to dd a mapping from a flyte uri to a remote path + """ + with cls._lock: + cls._flyte_path_to_remote_map[flyte_uri] = remote_path + + +class HttpFileWriter(fsspec.spec.AbstractBufferedFile): + def __init__(self, remote: FlyteRemote, filename: str, **kwargs): + super().__init__(**kwargs) + self._remote = remote + self._filename = filename + + def _upload_chunk(self, final=False): + """Only uploads the file at once from the buffer. + Not suitable for large files as the buffer will blow the memory for very large files. + Suitable for default values or local dataframes being uploaded all at once. + """ + if final is False: + return False + self.buffer.seek(0) + data = self.buffer.read() + + # h = hashlib.md5() + # h.update(data) + # md5 = h.digest() + # l = len(data) + # + # headers = {"Content-Length": str(l), "Content-MD5": md5} + + try: + res = self._remote.client.get_upload_signed_url( + self._remote.default_project, + self._remote.default_domain, + None, + None, + filename_root=self._filename, + ) + FlytePathResolver.add_mapping(self.path, res.native_url) + resp = requests.put(res.signed_url, data=data) + if not resp.ok: + raise AssertionError(f"Failed to upload file {self._filename} to {res.signed_url} reason {resp.reason}") + except Exception as e: + raise AssertionError(f"Failed to upload file {self._filename} reason {e}") + + +def get_flyte_fs(remote: FlyteRemote) -> typing.Type[FlyteFS]: + class _FlyteFS(FlyteFS): + def __init__(self, **storage_options): + super().__init__(remote=remote, **storage_options) + + return _FlyteFS + + +class FlyteFS(HTTPFileSystem): + """ + Want this to behave mostly just like the HTTP file system. + """ + + sep = "/" + protocol = "flyte" + + def __init__( + self, + remote: FlyteRemote, + asynchronous: bool = False, + **storage_options, + ): + super().__init__(asynchronous=asynchronous, **storage_options) + self._remote = remote + + @property + def fsid(self) -> str: + return "flyte" + + async def _get_file(self, rpath, lpath, **kwargs): + """ + Don't do anything special. If it's a flyte url, the create a download link and write to lpath, + otherwise default to parent. + """ + raise NotImplementedError("FlyteFS currently doesn't support downloading files.") + + def get_upload_link( + self, + local_file_path: str, + remote_file_part: str, + prefix: str, + hashes: HashStructure, + ) -> typing.Tuple[CreateUploadLocationResponse, int, bytes]: + if not pathlib.Path(local_file_path).exists(): + raise AssertionError(f"File {local_file_path} does not exist") + + p = pathlib.Path(typing.cast(str, local_file_path)) + k = str(p.absolute()) + if k in hashes: + md5_bytes, content_length = hashes[k] + else: + raise AssertionError(f"File {local_file_path} not found in hashes") + upload_response = self._remote.client.get_upload_signed_url( + self._remote.default_project, + self._remote.default_domain, + md5_bytes, + remote_file_part, + filename_root=prefix, + ) + logger.debug(f"Resolved signed url {local_file_path} to {upload_response.native_url}") + return upload_response, content_length, md5_bytes + + async def _put_file( + self, + lpath, + rpath, + chunk_size=5 * 2**20, + callback=_DEFAULT_CALLBACK, + method="put", + **kwargs, + ): + """ + fsspec will call this method to upload a file. If recursive, rpath will already be individual files. + Make the request and upload, but then how do we get the s3 paths back to the user? + """ + # remove from kwargs otherwise super() call will fail + p = kwargs.pop(_PREFIX_KEY) + hashes = kwargs.pop(_HASHES_KEY) + # Parse rpath, strip out everything that doesn't make sense. + rpath = rpath.replace(f"{REMOTE_PLACEHOLDER}/", "", 1) + resp, content_length, md5_bytes = self.get_upload_link(lpath, rpath, p, hashes) + + headers = {"Content-Length": str(content_length), "Content-MD5": b64encode(md5_bytes).decode("utf-8")} + kwargs["headers"] = headers + rpath = resp.signed_url + FlytePathResolver.add_mapping(rpath, resp.native_url) + logger.debug(f"Writing {lpath} to {rpath}") + await super()._put_file(lpath, rpath, chunk_size, callback=callback, method=method, **kwargs) + return resp.native_url + + @staticmethod + def extract_common(native_urls: typing.List[str]) -> str: + """ + This function that will take a list of strings and return the longest prefix that they all have in common. + That is, if you have + ['s3://my-s3-bucket/flytesnacks/development/ABCYZWMPACZAJ2MABGMOZ6CCPY======/source/empty.md', + 's3://my-s3-bucket/flytesnacks/development/ABCXKL5ZZWXY3PDLM3OONUHHME======/source/nested/more.txt', + 's3://my-s3-bucket/flytesnacks/development/ABCXBAPBKONMADXVW5Q3J6YBWM======/source/original.txt'] + this will return back 's3://my-s3-bucket/flytesnacks/development/' + Note that trailing characters after a separator that just happen to be the same will also be stripped. + """ + if len(native_urls) == 0: + return "" + if len(native_urls) == 1: + return native_urls[0] + + common_prefix = "" + shortest = min([len(x) for x in native_urls]) + x = [[native_urls[j][i] for j in range(len(native_urls))] for i in range(shortest)] + for i in x: + if len(set(i)) == 1: + common_prefix += i[0] + else: + break + + fs = fsspec.filesystem(get_protocol(native_urls[0])) + sep = fs.sep + # split the common prefix on the last separator so we don't get any trailing characters. + common_prefix = common_prefix.rsplit(sep, 1)[0] + logger.debug(f"Returning {common_prefix} from {native_urls}") + return common_prefix + + def get_hashes_and_lengths(self, p: pathlib.Path) -> HashStructure: + """ + Returns a flat list of absolute file paths to their hashes and content lengths + this output is used both for the file upload request, and to create consistently a filename root for + uploaded folders. We'll also use it for single files just for consistency. + If a directory then all the files in the directory will be hashed. + If a single file then just that file will be hashed. + Skip symlinks + """ + if p.is_symlink(): + return {} + if p.is_dir(): + hashes = {} + for f in p.iterdir(): + hashes.update(self.get_hashes_and_lengths(f)) + return hashes + else: + md5_bytes, _, content_length = hash_file(p.resolve()) + return {str(p.absolute()): (md5_bytes, content_length)} + + @staticmethod + def get_filename_root(file_info: HashStructure) -> str: + """ + Given a dictionary of file paths to hashes and content lengths, return a consistent filename root. + This is done by hashing the sorted list of file paths and then base32 encoding the result. + If the input is empty, then generate a random string + """ + if len(file_info) == 0: + return UUID(int=random.getrandbits(128)).hex + sorted_paths = sorted(file_info.keys()) + h = hashlib.md5() + for p in sorted_paths: + h.update(file_info[p][0]) + return base64.b32encode(h.digest()).decode("utf-8") + + async def _put( + self, + lpath, + rpath, + recursive=False, + callback=_DEFAULT_CALLBACK, + batch_size=None, + **kwargs, + ): + """ + cp file.txt flyte://data/... + rpath gets ignored, so it doesn't matter what it is. + """ + if rpath != REMOTE_PLACEHOLDER: + logger.debug(f"FlyteFS doesn't yet support specifying full remote path, ignoring {rpath}") + + # Hash everything at the top level + file_info = self.get_hashes_and_lengths(pathlib.Path(lpath)) + prefix = self.get_filename_root(file_info) + + kwargs[_PREFIX_KEY] = prefix + kwargs[_HASHES_KEY] = file_info + res = await super()._put(lpath, REMOTE_PLACEHOLDER, recursive, callback, batch_size, **kwargs) + if isinstance(res, list): + res = self.extract_common(res) + return res + + async def _isdir(self, path): + return True + + def exists(self, path, **kwargs): + raise NotImplementedError("flyte file system currently can't check if a file exists.") + + def _open( + self, + path, + mode="wb", + block_size=None, + autocommit=None, # XXX: This differs from the base class. + cache_type=None, + cache_options=None, + size=None, + **kwargs, + ): + if mode != "wb": + raise ValueError("Only wb mode is supported") + + # Dataframes are written as multiple files, default is the first file with 00000 suffix, we should drop + # that suffix and use the parent directory as the remote path. + + return HttpFileWriter( + self._remote, os.path.basename(path), fs=self, path=os.path.dirname(path), mode=mode, **kwargs + ) + + def __str__(self): + p = super().__str__() + return f"FlyteFS({self._remote}): {p}" diff --git a/flytekit/flytekit/sensor/__init__.py b/flytekit/flytekit/sensor/__init__.py new file mode 100644 index 0000000000..796088e74d --- /dev/null +++ b/flytekit/flytekit/sensor/__init__.py @@ -0,0 +1,3 @@ +from .base_sensor import BaseSensor +from .file_sensor import FileSensor +from .sensor_engine import SensorEngine diff --git a/flytekit/flytekit/sensor/base_sensor.py b/flytekit/flytekit/sensor/base_sensor.py new file mode 100644 index 0000000000..0e40055ea5 --- /dev/null +++ b/flytekit/flytekit/sensor/base_sensor.py @@ -0,0 +1,66 @@ +import collections +import inspect +from abc import abstractmethod +from typing import Any, Dict, Optional, TypeVar + +import jsonpickle +from typing_extensions import get_type_hints + +from flytekit.configuration import SerializationSettings +from flytekit.core.base_task import PythonTask +from flytekit.core.interface import Interface +from flytekit.extend.backend.base_agent import AsyncAgentExecutorMixin + +T = TypeVar("T") +SENSOR_MODULE = "sensor_module" +SENSOR_NAME = "sensor_name" +SENSOR_CONFIG_PKL = "sensor_config_pkl" +INPUTS = "inputs" + + +class BaseSensor(AsyncAgentExecutorMixin, PythonTask): + """ + Base class for all sensors. Sensors are tasks that are designed to run forever, and periodically check for some + condition to be met. When the condition is met, the sensor will complete. Sensors are designed to be run by the + sensor agent, and not by the Flyte engine. + """ + + def __init__( + self, + name: str, + sensor_config: Optional[T] = None, + task_type: str = "sensor", + **kwargs, + ): + type_hints = get_type_hints(self.poke, include_extras=True) + signature = inspect.signature(self.poke) + inputs = collections.OrderedDict() + for k, _ in signature.parameters.items(): # type: ignore + annotation = type_hints.get(k, None) + inputs[k] = annotation + + super().__init__( + task_type=task_type, + name=name, + task_config=None, + interface=Interface(inputs=inputs), + **kwargs, + ) + self._sensor_config = sensor_config + + @abstractmethod + async def poke(self, **kwargs) -> bool: + """ + This method should be overridden by the user to implement the actual sensor logic. This method should return + ``True`` if the sensor condition is met, else ``False``. + """ + raise NotImplementedError + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + cfg = { + SENSOR_MODULE: type(self).__module__, + SENSOR_NAME: type(self).__name__, + } + if self._sensor_config is not None: + cfg[SENSOR_CONFIG_PKL] = jsonpickle.encode(self._sensor_config) + return cfg diff --git a/flytekit/flytekit/sensor/file_sensor.py b/flytekit/flytekit/sensor/file_sensor.py new file mode 100644 index 0000000000..2fb3d64ec1 --- /dev/null +++ b/flytekit/flytekit/sensor/file_sensor.py @@ -0,0 +1,18 @@ +from typing import Optional, TypeVar + +from flytekit import FlyteContextManager +from flytekit.sensor.base_sensor import BaseSensor + +T = TypeVar("T") + + +class FileSensor(BaseSensor): + def __init__(self, name: str, config: Optional[T] = None, **kwargs): + super().__init__(name=name, sensor_config=config, **kwargs) + + async def poke(self, path: str) -> bool: + file_access = FlyteContextManager.current_context().file_access + fs = file_access.get_filesystem_for_path(path, asynchronous=True) + if file_access.is_remote(path): + return await fs._exists(path) + return fs.exists(path) diff --git a/flytekit/flytekit/sensor/sensor_engine.py b/flytekit/flytekit/sensor/sensor_engine.py new file mode 100644 index 0000000000..816360715a --- /dev/null +++ b/flytekit/flytekit/sensor/sensor_engine.py @@ -0,0 +1,62 @@ +import importlib +import typing +from typing import Optional + +import cloudpickle +import jsonpickle +from flyteidl.admin.agent_pb2 import ( + CreateTaskResponse, + DeleteTaskResponse, + GetTaskResponse, + Resource, +) +from flyteidl.core.execution_pb2 import TaskExecution + +from flytekit import FlyteContextManager +from flytekit.core.type_engine import TypeEngine +from flytekit.extend.backend.base_agent import AgentBase, AgentRegistry +from flytekit.models.literals import LiteralMap +from flytekit.models.task import TaskTemplate +from flytekit.sensor.base_sensor import INPUTS, SENSOR_CONFIG_PKL, SENSOR_MODULE, SENSOR_NAME + +T = typing.TypeVar("T") + + +class SensorEngine(AgentBase): + name = "Sensor" + + def __init__(self): + super().__init__(task_type="sensor") + + async def create( + self, output_prefix: str, task_template: TaskTemplate, inputs: Optional[LiteralMap] = None, **kwargs + ) -> CreateTaskResponse: + python_interface_inputs = { + name: TypeEngine.guess_python_type(lt.type) for name, lt in task_template.interface.inputs.items() + } + ctx = FlyteContextManager.current_context() + if inputs: + native_inputs = TypeEngine.literal_map_to_kwargs(ctx, inputs, python_interface_inputs) + task_template.custom[INPUTS] = native_inputs + return CreateTaskResponse(resource_meta=cloudpickle.dumps(task_template.custom)) + + async def get(self, resource_meta: bytes, **kwargs) -> GetTaskResponse: + meta = cloudpickle.loads(resource_meta) + + sensor_module = importlib.import_module(name=meta[SENSOR_MODULE]) + sensor_def = getattr(sensor_module, meta[SENSOR_NAME]) + sensor_config = jsonpickle.decode(meta[SENSOR_CONFIG_PKL]) if meta.get(SENSOR_CONFIG_PKL) else None + + inputs = meta.get(INPUTS, {}) + cur_phase = ( + TaskExecution.SUCCEEDED + if await sensor_def("sensor", config=sensor_config).poke(**inputs) + else TaskExecution.RUNNING + ) + return GetTaskResponse(resource=Resource(phase=cur_phase, outputs=None)) + + async def delete(self, resource_meta: bytes, **kwargs) -> DeleteTaskResponse: + return DeleteTaskResponse() + + +AgentRegistry.register(SensorEngine()) diff --git a/flytekit/flytekit/testing/__init__.py b/flytekit/flytekit/testing/__init__.py new file mode 100644 index 0000000000..06b69612e5 --- /dev/null +++ b/flytekit/flytekit/testing/__init__.py @@ -0,0 +1,20 @@ +""" +===================== +Unit Testing +===================== + +.. currentmodule:: flytekit.testing + +The imports exposed in this package will help you unit test your Flyte tasks. These are particularly helpful when +testing workflows that contain tasks that cannot run locally (a Hive task for instance). + +.. autosummary:: + :toctree: generated/ + + patch - A decorator similar to the regular one you're probably used to + task_mock - Non-decorative function + +""" + +from flytekit.core.context_manager import SecretsManager +from flytekit.core.testing import patch, task_mock diff --git a/flytekit/flytekit/tools/__init__.py b/flytekit/flytekit/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/tools/fast_registration.py b/flytekit/flytekit/tools/fast_registration.py new file mode 100644 index 0000000000..4b018ce94b --- /dev/null +++ b/flytekit/flytekit/tools/fast_registration.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import gzip +import hashlib +import os +import posixpath +import subprocess as _subprocess +import tarfile +import tempfile +import typing +from typing import Optional + +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 + +FAST_PREFIX = "fast" +FAST_FILEENDING = ".tar.gz" + + +def fast_package(source: os.PathLike, output_dir: os.PathLike, deref_symlinks: bool = False) -> os.PathLike: + """ + Takes a source directory and packages everything not covered by common ignores into a tarball + named after a hexdigest of the included files. + :param os.PathLike source: + :param os.PathLike output_dir: + :param bool deref_symlinks: Enables dereferencing symlinks when packaging directory + :return os.PathLike: + """ + ignore = IgnoreGroup(source, [GitIgnore, DockerIgnore, StandardIgnore]) + digest = compute_digest(source, ignore.is_ignored) + archive_fname = f"{FAST_PREFIX}{digest}{FAST_FILEENDING}" + + if output_dir is None: + output_dir = tempfile.mkdtemp() + click.secho(f"No output path provided, using a temporary directory at {output_dir} instead", fg="yellow") + + archive_fname = os.path.join(output_dir, archive_fname) + + with tempfile.TemporaryDirectory() as tmp_dir: + tar_path = os.path.join(tmp_dir, "tmp.tar") + with tarfile.open(tar_path, "w", dereference=deref_symlinks) as tar: + files: typing.List[str] = os.listdir(source) + for ws_file in files: + tar.add( + os.path.join(source, ws_file), + arcname=ws_file, + filter=lambda x: ignore.tar_filter(tar_strip_file_attributes(x)), + ) + with gzip.GzipFile(filename=archive_fname, mode="wb", mtime=0) as gzipped: + with open(tar_path, "rb") as tar_file: + gzipped.write(tar_file.read()) + + return archive_fname + + +def compute_digest(source: os.PathLike, filter: Optional[callable] = None) -> str: + """ + Walks the entirety of the source dir to compute a deterministic md5 hex digest of the dir contents. + :param os.PathLike source: + :param Ignore ignore: + :return Text: + """ + hasher = hashlib.md5() + for root, _, files in os.walk(source, topdown=True): + files.sort() + + for fname in files: + abspath = os.path.join(root, fname) + relpath = os.path.relpath(abspath, source) + if filter: + if filter(relpath): + continue + + _filehash_update(abspath, hasher) + _pathhash_update(relpath, hasher) + + return hasher.hexdigest() + + +def _filehash_update(path: os.PathLike, hasher: hashlib._Hash) -> None: + blocksize = 65536 + with open(path, "rb") as f: + bytes = f.read(blocksize) + while bytes: + hasher.update(bytes) + bytes = f.read(blocksize) + + +def _pathhash_update(path: os.PathLike, hasher: hashlib._Hash) -> None: + path_list = path.split(os.sep) + hasher.update("".join(path_list).encode("utf-8")) + + +def get_additional_distribution_loc(remote_location: str, identifier: str) -> str: + """ + :param Text remote_location: + :param Text identifier: + :return Text: + """ + 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. + :param Text additional_distribution: + :param os.PathLike destination: + """ + if not os.path.isdir(destination): + raise ValueError("Destination path is required to download distribution and it should be a directory") + # NOTE the os.path.join(destination, ''). This is to ensure that the given path is in fact a directory and all + # downloaded data should be copied into this directory. We do this to account for a difference in behavior in + # fsspec, which requires a trailing slash in case of pre-existing directory. + FlyteContextManager.current_context().file_access.get_data(additional_distribution, os.path.join(destination, "")) + tarfile_name = os.path.basename(additional_distribution) + if not tarfile_name.endswith(".tar.gz"): + raise RuntimeError("Unrecognized additional distribution format for {}".format(additional_distribution)) + + # This will overwrite the existing user flyte workflow code in the current working code dir. + result = _subprocess.run( + ["tar", "-xvf", os.path.join(destination, tarfile_name), "-C", destination], + stdout=_subprocess.PIPE, + ) + result.check_returncode() diff --git a/flytekit/flytekit/tools/ignore.py b/flytekit/flytekit/tools/ignore.py new file mode 100644 index 0000000000..49c4154959 --- /dev/null +++ b/flytekit/flytekit/tools/ignore.py @@ -0,0 +1,125 @@ +import os +import subprocess +import tarfile as _tarfile +from abc import ABC, abstractmethod +from fnmatch import fnmatch +from pathlib import Path +from shutil import which +from typing import Dict, List, Optional, Type + +from docker.utils.build import PatternMatcher + +from flytekit.loggers import logger + +STANDARD_IGNORE_PATTERNS = ["*.pyc", ".cache", ".cache/*", "__pycache__", "**/__pycache__"] + + +class Ignore(ABC): + """Base for Ignores, implements core logic. Children have to implement _is_ignored""" + + def __init__(self, root: str): + self.root = root + + def is_ignored(self, path: str) -> bool: + if os.path.isabs(path): + path = os.path.relpath(path, self.root) + return self._is_ignored(path) + + def tar_filter(self, tarinfo: _tarfile.TarInfo) -> Optional[_tarfile.TarInfo]: + if self.is_ignored(tarinfo.name): + return None + return tarinfo + + @abstractmethod + def _is_ignored(self, path: str) -> bool: + pass + + +class GitIgnore(Ignore): + """Uses git cli (if available) to list all ignored files and compare with those.""" + + def __init__(self, root: Path): + super().__init__(root) + self.has_git = which("git") is not None + self.ignored = self._list_ignored() + + def _list_ignored(self) -> Dict: + if self.has_git: + out = subprocess.run(["git", "ls-files", "-io", "--exclude-standard"], cwd=self.root, capture_output=True) + if out.returncode == 0: + return dict.fromkeys(out.stdout.decode("utf-8").split("\n")[:-1]) + logger.warning(f"Could not determine ignored files due to:\n{out.stderr}\nNot applying any filters") + return {} + logger.info("No git executable found, not applying any filters") + return {} + + def _is_ignored(self, path: str) -> bool: + if self.ignored: + # git-ls-files uses POSIX paths + if Path(path).as_posix() in self.ignored: + return True + # Ignore empty directories + if os.path.isdir(os.path.join(self.root, path)) and all( + [self.is_ignored(os.path.join(path, f)) for f in os.listdir(os.path.join(self.root, path))] + ): + return True + return False + + +class DockerIgnore(Ignore): + """Uses docker-py's PatternMatcher to check whether a path is ignored.""" + + def __init__(self, root: Path): + super().__init__(root) + self.pm = self._parse() + + def _parse(self) -> PatternMatcher: + patterns = [] + dockerignore = os.path.join(self.root, ".dockerignore") + if os.path.isfile(dockerignore): + with open(dockerignore, "r") as f: + patterns = [l.strip() for l in f.readlines() if l and not l.startswith("#")] + logger.info(f"No .dockerignore found in {self.root}, not applying any filters") + return PatternMatcher(patterns) + + def _is_ignored(self, path: str) -> bool: + return self.pm.matches(path) + + +class StandardIgnore(Ignore): + """Retains the standard ignore functionality that previously existed. Could in theory + by fed with custom ignore patterns from cli.""" + + def __init__(self, root: Path, patterns: Optional[List[str]] = None): + super().__init__(root) + self.patterns = patterns if patterns else STANDARD_IGNORE_PATTERNS + + def _is_ignored(self, path: str) -> bool: + for pattern in self.patterns: + if fnmatch(path, pattern): + return True + return False + + +class IgnoreGroup(Ignore): + """Groups multiple Ignores and checks a path against them. A file is ignored if any + Ignore considers it ignored.""" + + def __init__(self, root: str, ignores: List[Type[Ignore]]): + super().__init__(root) + self.ignores = [ignore(root) for ignore in ignores] + + def _is_ignored(self, path: str) -> bool: + for ignore in self.ignores: + if ignore.is_ignored(path): + return True + return False + + def list_ignored(self) -> List[str]: + ignored = [] + for root, _, files in os.walk(self.root): + for file in files: + abs_path = os.path.join(root, file) + if self.is_ignored(abs_path): + ignored.append(os.path.relpath(abs_path, self.root)) + return ignored diff --git a/flytekit/flytekit/tools/interactive.py b/flytekit/flytekit/tools/interactive.py new file mode 100644 index 0000000000..a0d022df18 --- /dev/null +++ b/flytekit/flytekit/tools/interactive.py @@ -0,0 +1,14 @@ +def ipython_check() -> bool: + """ + Check if interface is launching from iPython (not colab) + :return is_ipython (bool): True or False + """ + is_ipython = False + try: # Check if running interactively using ipython. + from IPython import get_ipython + + if get_ipython() is not None: + is_ipython = True + except (ImportError, NameError): + pass + return is_ipython diff --git a/flytekit/flytekit/tools/module_loader.py b/flytekit/flytekit/tools/module_loader.py new file mode 100644 index 0000000000..dc3a6bb9f4 --- /dev/null +++ b/flytekit/flytekit/tools/module_loader.py @@ -0,0 +1,44 @@ +import contextlib +import importlib +import os +import pkgutil +import sys +from typing import Any, Iterator, List, Union + + +@contextlib.contextmanager +def add_sys_path(path: Union[str, os.PathLike]) -> Iterator[None]: + """Temporarily add given path to `sys.path`.""" + path = os.fspath(path) + try: + sys.path.insert(0, path) + yield + finally: + sys.path.remove(path) + + +def just_load_modules(pkgs: List[str]): + """ + This one differs from the above in that we don't yield anything, just load all the modules. + """ + for package_name in pkgs: + package = importlib.import_module(package_name) + + # If it doesn't have a __path__ field, that means it's not a package, just a module + if not hasattr(package, "__path__"): + continue + + # Note that walk_packages takes an onerror arg and swallows import errors silently otherwise + for _, name, _ in pkgutil.walk_packages(package.__path__, prefix=f"{package_name}."): + importlib.import_module(name) + + +def load_object_from_module(object_location: str) -> Any: + """ + # TODO: Handle corner cases, like where the first part is [] maybe + """ + class_obj = object_location.split(".") + class_obj_mod = class_obj[:-1] # e.g. ['flytekit', 'core', 'python_auto_container'] + class_obj_key = class_obj[-1] # e.g. 'default_task_class_obj' + class_obj_mod = importlib.import_module(".".join(class_obj_mod)) + return getattr(class_obj_mod, class_obj_key) diff --git a/flytekit/flytekit/tools/repo.py b/flytekit/flytekit/tools/repo.py new file mode 100644 index 0000000000..c3a456c20b --- /dev/null +++ b/flytekit/flytekit/tools/repo.py @@ -0,0 +1,299 @@ +import os +import tarfile +import tempfile +import typing +from pathlib import Path + +import click + +from flytekit.configuration import FastSerializationSettings, ImageConfig, SerializationSettings +from flytekit.core.context_manager import FlyteContextManager +from flytekit.loggers import logger +from flytekit.models import launch_plan +from flytekit.models.core.identifier import Identifier +from flytekit.remote import FlyteRemote +from flytekit.remote.remote import RegistrationSkipped, _get_git_repo_url +from flytekit.tools import fast_registration, module_loader +from flytekit.tools.script_mode import _find_project_root +from flytekit.tools.serialize_helpers import get_registrable_entities, persist_registrable_entities +from flytekit.tools.translator import FlyteControlPlaneEntity, Options + + +class NoSerializableEntitiesError(Exception): + pass + + +def serialize( + pkgs: typing.List[str], + settings: SerializationSettings, + local_source_root: typing.Optional[str] = None, + options: typing.Optional[Options] = None, +) -> typing.List[FlyteControlPlaneEntity]: + """ + See :py:class:`flytekit.models.core.identifier.ResourceType` to match the trailing index in the file name with the + entity type. + :param options: + :param settings: SerializationSettings to be used + :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 + with module_loader.add_sys_path(local_source_root): + click.secho(f"Loading packages {pkgs} under source root {local_source_root}", fg="yellow") + module_loader.just_load_modules(pkgs=pkgs) + + registrable_entities = get_registrable_entities(ctx, options=options) + click.secho(f"Successfully serialized {len(registrable_entities)} flyte objects", fg="green") + return registrable_entities + + +def serialize_to_folder( + pkgs: typing.List[str], + settings: SerializationSettings, + local_source_root: typing.Optional[str] = None, + folder: str = ".", + options: typing.Optional[Options] = None, +): + """ + 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) + + +def package( + serializable_entities: typing.List[FlyteControlPlaneEntity], + source: str = ".", + output: str = "./flyte-package.tgz", + fast: bool = False, + deref_symlinks: bool = False, +): + """ + Package the given entities and the source code (if fast is enabled) into a package with the given name in output + :param serializable_entities: Entities that can be serialized + :param source: source folder + :param output: output package name with suffix + :param fast: fast enabled implies source code is bundled + :param deref_symlinks: if enabled then symlinks are dereferenced during packaging + """ + if not serializable_entities: + raise NoSerializableEntitiesError("Nothing to package") + + with tempfile.TemporaryDirectory() as output_tmpdir: + persist_registrable_entities(serializable_entities, output_tmpdir) + + # If Fast serialization is enabled, then an archive is also created and packaged + if fast: + # If output exists and is a path within source, delete it so as to not re-bundle it again. + if os.path.abspath(output).startswith(os.path.abspath(source)) and os.path.exists(output): + click.secho(f"{output} already exists within {source}, deleting and re-creating it", fg="yellow") + os.remove(output) + archive_fname = fast_registration.fast_package(source, output_tmpdir, deref_symlinks) + click.secho(f"Fast mode enabled: compressed archive {archive_fname}", dim=True) + + with tarfile.open(output, "w:gz") as tar: + files: typing.List[str] = os.listdir(output_tmpdir) + for ws_file in files: + tar.add(os.path.join(output_tmpdir, ws_file), arcname=ws_file) + + click.secho(f"Successfully packaged {len(serializable_entities)} flyte objects into {output}", fg="green") + + +def serialize_and_package( + pkgs: typing.List[str], + settings: SerializationSettings, + source: str = ".", + output: str = "./flyte-package.tgz", + fast: bool = False, + deref_symlinks: bool = False, + options: typing.Optional[Options] = None, +): + """ + Fist serialize and then package all entities + """ + serializable_entities = serialize(pkgs, settings, source, options=options) + package(serializable_entities, source, output, fast, deref_symlinks) + + +def find_common_root( + pkgs_or_mods: typing.Union[typing.Tuple[str], typing.List[str]], +) -> Path: + """ + Given an arbitrary list of folders and files, this function will use the script mode function to walk up + the filesystem to find the first folder without an init file. If all the folders and files resolve to + the same root folder, then that Path is returned. Otherwise an error is raised. + + :param pkgs_or_mods: + :return: The common detected root path, the output of _find_project_root + """ + project_root = None + for pm in pkgs_or_mods: + root = _find_project_root(pm) + if project_root is None: + project_root = root + else: + if project_root != root: + raise ValueError(f"Specified module {pm} has root {root} but {project_root} already specified") + + logger.debug(f"Common root folder detected as {str(project_root)}") + + return project_root + + +def load_packages_and_modules( + ss: SerializationSettings, + project_root: Path, + pkgs_or_mods: typing.List[str], + options: typing.Optional[Options] = None, +) -> typing.List[FlyteControlPlaneEntity]: + """ + The project root is added as the first entry to sys.path, and then all the specified packages and modules + given are loaded with all submodules. The reason for prepending the entry is to ensure that the name that + the various modules are loaded under are the fully-resolved name. + + For example, using flytesnacks cookbook, if you are in core/ and you call this function with + ``flyte_basics/hello_world.py control_flow/``, the ``hello_world`` module would be loaded + as ``core.flyte_basics.hello_world`` even though you're already in the core/ folder. + + :param ss: + :param project_root: + :param pkgs_or_mods: + :param options: + :return: The common detected root path, the output of _find_project_root + """ + ss.git_repo = _get_git_repo_url(project_root) + pkgs_and_modules = [] + for pm in pkgs_or_mods: + p = Path(pm).resolve() + rel_path_from_root = p.relative_to(project_root) + # One day we should learn how to do this right. This is not the right way to load a python module + # from a file. See pydoc.importfile for inspiration + dot_delineated = os.path.splitext(rel_path_from_root)[0].replace(os.path.sep, ".") # noqa + + logger.debug( + f"User specified arg {pm} has {str(rel_path_from_root)} relative path loading it as {dot_delineated}" + ) + pkgs_and_modules.append(dot_delineated) + + registrable_entities = serialize(pkgs_and_modules, ss, str(project_root), options) + + return registrable_entities + + +def secho(i: Identifier, state: str = "success", reason: str = None, op: str = "Registration"): + state_ind = "[ ]" + fg = "white" + nl = False + if state == "success": + state_ind = "\r[✔]" + fg = "green" + nl = True + reason = f"successful with version {i.version}" if not reason else reason + elif state == "failed": + state_ind = "\r[x]" + fg = "red" + nl = True + reason = "skipped!" + click.secho( + click.style(f"{state_ind}", fg=fg) + f" {op} {i.name} type {i.resource_type_name()} {reason}", + dim=True, + nl=nl, + ) + + +def register( + project: str, + domain: str, + image_config: ImageConfig, + output: str, + destination_dir: str, + service_account: str, + raw_data_prefix: str, + version: typing.Optional[str], + deref_symlinks: bool, + fast: bool, + package_or_module: typing.Tuple[str], + remote: FlyteRemote, + env: typing.Optional[typing.Dict[str, str]], + dry_run: bool = False, + activate_launchplans: bool = False, + skip_errors: bool = False, +): + detected_root = find_common_root(package_or_module) + click.secho(f"Detected Root {detected_root}, using this to create deployable package...", fg="yellow") + fast_serialization_settings = None + if fast: + md5_bytes, native_url = remote.fast_package(detected_root, deref_symlinks, output) + fast_serialization_settings = FastSerializationSettings( + enabled=True, + destination_dir=destination_dir, + distribution_location=native_url, + ) + + # Create serialization settings + # Todo: Rely on default Python interpreter for now, this will break custom Spark containers + serialization_settings = SerializationSettings( + project=project, + domain=domain, + version=version, + image_config=image_config, + fast_serialization_settings=fast_serialization_settings, + env=env, + ) + + if not version and fast: + version = remote._version_from_hash(md5_bytes, serialization_settings, service_account, raw_data_prefix) # noqa + click.secho(f"Computed version is {version}", fg="yellow") + elif not version: + click.secho("Version is required.", fg="red") + return + + b = serialization_settings.new_builder() + b.version = version + serialization_settings = b.build() + + options = Options.default_from(k8s_service_account=service_account, raw_data_prefix=raw_data_prefix) + + # Load all the entities + FlyteContextManager.push_context(remote.context) + registrable_entities = load_packages_and_modules( + serialization_settings, detected_root, list(package_or_module), options + ) + FlyteContextManager.pop_context() + if len(registrable_entities) == 0: + click.secho("No Flyte entities were detected. Aborting!", fg="red") + return + + for cp_entity in registrable_entities: + is_lp = False + if isinstance(cp_entity, launch_plan.LaunchPlan): + og_id = cp_entity.id + is_lp = True + else: + og_id = cp_entity.template.id + secho(og_id, "") + try: + if not dry_run: + try: + i = remote.raw_register( + cp_entity, serialization_settings, version=version, create_default_launchplan=False + ) + secho(i, state="success") + if is_lp and activate_launchplans: + secho(og_id, "", op="Activation") + remote.activate_launchplan(i) + secho(i, reason="activated", op="Activation") + except Exception as e: + if not skip_errors: + raise e + secho(og_id, state="failed") + else: + secho(og_id, reason="Dry run Mode!") + except RegistrationSkipped: + secho(og_id, "failed") + click.secho(f"Successfully registered {len(registrable_entities)} entities", fg="green") diff --git a/flytekit/flytekit/tools/script_mode.py b/flytekit/flytekit/tools/script_mode.py new file mode 100644 index 0000000000..fba454ce76 --- /dev/null +++ b/flytekit/flytekit/tools/script_mode.py @@ -0,0 +1,161 @@ +import gzip +import hashlib +import importlib +import os +import shutil +import tarfile +import tempfile +import typing +from pathlib import Path + +from flytekit import PythonFunctionTask +from flytekit.core.tracker import get_full_module_path +from flytekit.core.workflow import ImperativeWorkflow, WorkflowBase + + +def compress_scripts(source_path: str, destination: str, module_name: str): + """ + Compresses the single script while maintaining the folder structure for that file. + + For example, given the follow file structure: + . + ├── flyte + │   ├── __init__.py + │   └── workflows + │   ├── example.py + │   ├── another_example.py + │   ├── yet_another_example.py + │   └── __init__.py + + Let's say you want to compress `example.py`. In that case we specify the the full module name as + flyte.workflows.example and that will produce a tar file that contains only that file alongside + with the folder structure, i.e.: + + . + ├── flyte + │   ├── __init__.py + │   └── workflows + │   ├── example.py + │   └── __init__.py + + 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") + + 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: + tmp_path: str = os.path.join(tmp_dir, "code") + files: typing.List[str] = os.listdir(tmp_path) + for ws_file in files: + tar.add(os.path.join(tmp_path, ws_file), arcname=ws_file, filter=tar_strip_file_attributes) + with gzip.GzipFile(filename=destination, mode="wb", mtime=0) as gzipped: + with open(tar_path, "rb") as tar_file: + 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 +# intended to be passed as a filter to tarfile.add +# https://docs.python.org/3/library/tarfile.html#tarfile.TarFile.add +def tar_strip_file_attributes(tar_info: tarfile.TarInfo) -> tarfile.TarInfo: + # set time to epoch timestamp 0, aka 00:00:00 UTC on 1 January 1970 + # note that when extracting this tarfile, this time will be shown as the modified date + tar_info.mtime = 0 + + # user/group info + tar_info.uid = 0 + tar_info.uname = "" + tar_info.gid = 0 + tar_info.gname = "" + + # stripping paxheaders may not be required + # see https://stackoverflow.com/questions/34688392/paxheaders-in-tarball + tar_info.pax_headers = {} + + return tar_info + + +def hash_file(file_path: typing.Union[os.PathLike, str]) -> (bytes, str, int): + """ + Hash a file and produce a digest to be used as a version + """ + h = hashlib.md5() + l = 0 + + with open(file_path, "rb") as file: + while True: + # Reading is buffered, so we can read smaller chunks. + chunk = file.read(h.block_size) + if not chunk: + break + h.update(chunk) + l += len(chunk) + + return h.digest(), h.hexdigest(), l + + +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 + not contain a __init__.py file. + + N.B.: This assumption only holds for regular packages (as opposed to namespace packages) + """ + # Start from the directory right above source_path + path = Path(source_path).parent.resolve() + while os.path.exists(os.path.join(path, "__init__.py")): + path = path.parent + return str(path) diff --git a/flytekit/flytekit/tools/serialize_helpers.py b/flytekit/flytekit/tools/serialize_helpers.py new file mode 100644 index 0000000000..86a029d411 --- /dev/null +++ b/flytekit/flytekit/tools/serialize_helpers.py @@ -0,0 +1,100 @@ +import math +import os as _os +import sys +import typing +from collections import OrderedDict + +import click + +from flytekit import LaunchPlan +from flytekit.core import context_manager as flyte_context +from flytekit.core.base_task import PythonTask +from flytekit.core.workflow import WorkflowBase +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.task import TaskSpec +from flytekit.remote.remote_callable import RemoteEntity +from flytekit.tools.translator import FlyteControlPlaneEntity, Options, get_serializable + + +def _determine_text_chars(length): + """ + This function is used to help prefix files. If there are only 10 entries, then we just need one digit (0-9) to be + the prefix. If there are 11, then we'll need two (00-10). + + :param int length: + :rtype: int + """ + if length == 0: + return 0 + return math.ceil(math.log(length, 10)) + + +def _should_register_with_admin(entity) -> bool: + """ + This is used in the code below. The translator.py module produces lots of objects (namely nodes and BranchNodes) + that do not/should not be written to .pb file to send to admin. This function filters them out. + """ + return isinstance( + entity, (task_models.TaskSpec, _launch_plan_models.LaunchPlan, admin_workflow_models.WorkflowSpec) + ) and not isinstance(entity, RemoteEntity) + + +def get_registrable_entities( + ctx: flyte_context.FlyteContext, options: typing.Optional[Options] = None +) -> typing.List[FlyteControlPlaneEntity]: + """ + Returns all entities that can be serialized and should be sent over to Flyte backend. This will filter any entities + that are not known to Admin + """ + new_api_serializable_entities = OrderedDict() + # TODO: Clean up the copy() - it's here because we call get_default_launch_plan, which may create a LaunchPlan + # object, which gets added to the FlyteEntities.entities list, which we're iterating over. + for entity in flyte_context.FlyteEntities.entities.copy(): + if isinstance(entity, PythonTask) or isinstance(entity, WorkflowBase) or isinstance(entity, LaunchPlan): + get_serializable(new_api_serializable_entities, ctx.serialization_settings, entity, options=options) + + if isinstance(entity, WorkflowBase): + lp = LaunchPlan.get_default_launch_plan(ctx, entity) + get_serializable(new_api_serializable_entities, ctx.serialization_settings, lp, options) + + new_api_model_values = list(new_api_serializable_entities.values()) + entities_to_be_serialized = list(filter(_should_register_with_admin, new_api_model_values)) + + return entities_to_be_serialized + + +def persist_registrable_entities(entities: typing.List[FlyteControlPlaneEntity], folder: str): + """ + For protobuf serializable list of entities, writes a file with the name if the entity and + enumeration order to the specified folder + + This function will write to the folder specified the following protobuf types :: + flyteidl.admin.launch_plan_pb2.LaunchPlan + flyteidl.admin.workflow_pb2.WorkflowSpec + flyteidl.admin.task_pb2.TaskSpec + + These can be inspected by calling (in the launch plan case) :: + flyte-cli parse-proto -f filename.pb -p flyteidl.admin.launch_plan_pb2.LaunchPlan + """ + zero_padded_length = _determine_text_chars(len(entities)) + for i, entity in enumerate(entities): + fname_index = str(i).zfill(zero_padded_length) + if isinstance(entity, TaskSpec): + name = entity.template.id.name + fname = "{}_{}_1.pb".format(fname_index, entity.template.id.name) + elif isinstance(entity, WorkflowSpec): + name = entity.template.id.name + fname = "{}_{}_2.pb".format(fname_index, entity.template.id.name) + elif isinstance(entity, _launch_plan_models.LaunchPlan): + name = entity.id.name + fname = "{}_{}_3.pb".format(fname_index, entity.id.name) + else: + click.secho(f"Entity is incorrect formatted {entity} - type {type(entity)}", fg="red") + sys.exit(-1) + click.secho(f" Packaging {name} -> {fname}", dim=True) + fname = _os.path.join(folder, fname) + with open(fname, "wb") as writer: + writer.write(entity.serialize_to_string()) diff --git a/flytekit/flytekit/tools/subprocess.py b/flytekit/flytekit/tools/subprocess.py new file mode 100644 index 0000000000..58569bf8d8 --- /dev/null +++ b/flytekit/flytekit/tools/subprocess.py @@ -0,0 +1,30 @@ +import shlex as _schlex +import subprocess as _subprocess +import tempfile as _tempfile + +from flytekit.loggers import logger + + +def check_call(cmd_args, **kwargs): + if not isinstance(cmd_args, list): + cmd_args = _schlex.split(cmd_args) + + # Jupyter notebooks hijack I/O and thus we cannot dump directly to stdout. + with _tempfile.TemporaryFile() as std_out: + with _tempfile.TemporaryFile() as std_err: + ret_code = _subprocess.Popen(cmd_args, stdout=std_out, stderr=std_err, **kwargs).wait() + + # Dump sub-process' std out into current std out + std_out.seek(0) + logger.info("Output of command '{}':\n{}\n".format(cmd_args, std_out.read())) + + if ret_code != 0: + std_err.seek(0) + err_str = std_err.read() + logger.error("Error from command '{}':\n{}\n".format(cmd_args, err_str)) + + raise Exception( + "Called process exited with error code: {}. Stderr dump:\n\n{}".format(ret_code, err_str) + ) + + return 0 diff --git a/flytekit/flytekit/tools/translator.py b/flytekit/flytekit/tools/translator.py new file mode 100644 index 0000000000..1c2016b681 --- /dev/null +++ b/flytekit/flytekit/tools/translator.py @@ -0,0 +1,800 @@ +import sys +import typing +from collections import OrderedDict +from dataclasses import dataclass +from typing import Callable, Dict, List, Optional, Tuple, Union + +from flytekit import PythonFunctionTask, SourceCode +from flytekit.configuration import SerializationSettings +from flytekit.core import constants as _common_constants +from flytekit.core.array_node_map_task import ArrayNodeMapTask +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 +from flytekit.core.node import Node +from flytekit.core.python_auto_container import PythonAutoContainerTask +from flytekit.core.reference_entity import ReferenceEntity, ReferenceSpec, ReferenceTemplate +from flytekit.core.task import ReferenceTask +from flytekit.core.utils import ClassDecorator, _dnsify +from flytekit.core.workflow import ReferenceWorkflow, WorkflowBase +from flytekit.models import common as _common_models +from flytekit.models import common as common_models +from flytekit.models import interface as interface_models +from flytekit.models import launch_plan as _launch_plan_models +from flytekit.models import security +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_model +from flytekit.models.core import workflow as _core_wf +from flytekit.models.core import workflow as workflow_model +from flytekit.models.core.workflow import ApproveCondition, GateNode, SignalCondition, SleepCondition, TaskNodeOverrides +from flytekit.models.core.workflow import ArrayNode as ArrayNodeModel +from flytekit.models.core.workflow import BranchNode as BranchNodeModel +from flytekit.models.task import TaskSpec, TaskTemplate + +FlyteLocalEntity = Union[ + PythonTask, + BranchNode, + Node, + LaunchPlan, + WorkflowBase, + ReferenceWorkflow, + ReferenceTask, + ReferenceLaunchPlan, + ReferenceEntity, +] +FlyteControlPlaneEntity = Union[ + TaskSpec, + _launch_plan_models.LaunchPlan, + admin_workflow_models.WorkflowSpec, + workflow_model.Node, + BranchNodeModel, + ArrayNodeModel, +] + + +@dataclass +class Options(object): + """ + These are options that can be configured for a launchplan during registration or overridden during an execution. + For instance two people may want to run the same workflow but have the offloaded data stored in two different + buckets. Or you may want labels or annotations to be different. This object is used when launching an execution + in a Flyte backend, and also when registering launch plans. + + Args: + labels: Custom labels to be applied to the execution resource + annotations: Custom annotations to be applied to the execution resource + security_context: Indicates security context for permissions triggered with this launch plan + raw_output_data_config: Optional location of offloaded data for things like S3, etc. + remote prefix for storage location of the form ``s3:///key...`` or + ``gcs://...`` or ``file://...``. If not specified will use the platform configured default. This is where + the data for offloaded types is stored. + max_parallelism: Controls the maximum number of tasknodes that can be run in parallel for the entire workflow. + notifications: List of notifications for this execution. + disable_notifications: This should be set to true if all notifications are intended to be disabled for this execution. + """ + + labels: typing.Optional[common_models.Labels] = None + annotations: typing.Optional[common_models.Annotations] = None + raw_output_data_config: typing.Optional[common_models.RawOutputDataConfig] = None + security_context: typing.Optional[security.SecurityContext] = None + max_parallelism: typing.Optional[int] = None + notifications: typing.Optional[typing.List[common_models.Notification]] = None + disable_notifications: typing.Optional[bool] = None + + @classmethod + def default_from( + cls, k8s_service_account: typing.Optional[str] = None, raw_data_prefix: typing.Optional[str] = None + ) -> "Options": + return cls( + security_context=security.SecurityContext(run_as=security.Identity(k8s_service_account=k8s_service_account)) + if k8s_service_account + else None, + raw_output_data_config=common_models.RawOutputDataConfig(output_location_prefix=raw_data_prefix) + if raw_data_prefix + else None, + ) + + +def to_serializable_case( + entity_mapping: OrderedDict, + settings: SerializationSettings, + c: _core_wf.IfBlock, + options: Optional[Options] = None, +) -> _core_wf.IfBlock: + if c is None: + raise ValueError("Cannot convert none cases to registrable") + then_node = get_serializable(entity_mapping, settings, c.then_node, options=options) + return _core_wf.IfBlock(condition=c.condition, then_node=then_node) + + +def to_serializable_cases( + entity_mapping: OrderedDict, + settings: SerializationSettings, + cases: List[_core_wf.IfBlock], + options: Optional[Options] = None, +) -> Optional[List[_core_wf.IfBlock]]: + if cases is None: + return None + ret_cases = [] + for c in cases: + ret_cases.append(to_serializable_case(entity_mapping, settings, c, options)) + return ret_cases + + +def get_command_prefix_for_fast_execute(settings: SerializationSettings) -> List[str]: + return [ + "pyflyte-fast-execute", + "--additional-distribution", + settings.fast_serialization_settings.distribution_location + if settings.fast_serialization_settings and settings.fast_serialization_settings.distribution_location + else "{{ .remote_package_path }}", + "--dest-dir", + settings.fast_serialization_settings.destination_dir + if settings.fast_serialization_settings and settings.fast_serialization_settings.destination_dir + else "{{ .dest_dir }}", + "--", + ] + + +def prefix_with_fast_execute(settings: SerializationSettings, cmd: typing.List[str]) -> List[str]: + return get_command_prefix_for_fast_execute(settings) + cmd + + +def _fast_serialize_command_fn( + settings: SerializationSettings, task: PythonAutoContainerTask +) -> Callable[[SerializationSettings], List[str]]: + """ + This function is only applicable for Pod tasks. + """ + default_command = task.get_default_command(settings) + + def fn(settings: SerializationSettings) -> List[str]: + return prefix_with_fast_execute(settings, default_command) + + return fn + + +def get_serializable_task( + entity_mapping: OrderedDict, + settings: SerializationSettings, + entity: FlyteLocalEntity, + options: Optional[Options] = None, +) -> TaskSpec: + task_id = _identifier_model.Identifier( + _identifier_model.ResourceType.TASK, + settings.project, + settings.domain, + entity.name, + settings.version, + ) + + if isinstance(entity, PythonFunctionTask) and entity.execution_mode == PythonFunctionTask.ExecutionBehavior.DYNAMIC: + # In case of Dynamic tasks, we want to pass the serialization context, so that they can reconstruct the state + # from the serialization context. This is passed through an environment variable, that is read from + # during dynamic serialization + settings = settings.with_serialized_context() + + if entity.node_dependency_hints is not None: + for entity_hint in entity.node_dependency_hints: + get_serializable(entity_mapping, settings, entity_hint, options) + + container = entity.get_container(settings) + # This pod will be incorrect when doing fast serialize + pod = entity.get_k8s_pod(settings) + + if settings.should_fast_serialize(): + # This handles container tasks. + if container and isinstance(entity, (PythonAutoContainerTask, MapPythonTask, ArrayNodeMapTask)): + # For fast registration, we'll need to muck with the command, but on + # ly for certain kinds of tasks. Specifically, + # tasks that rely on user code defined in the container. This should be encapsulated by the auto container + # parent class + container._args = prefix_with_fast_execute(settings, container.args) + + # 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 and not isinstance(entity, ContainerTask): + if isinstance(entity, (MapPythonTask, ArrayNodeMapTask)): + entity.set_command_prefix(get_command_prefix_for_fast_execute(settings)) + pod = entity.get_k8s_pod(settings) + else: + entity.set_command_fn(_fast_serialize_command_fn(settings, entity)) + pod = entity.get_k8s_pod(settings) + entity.reset_command_fn() + + entity_config = entity.get_config(settings) or {} + + extra_config = {} + + if hasattr(entity, "task_function") and isinstance(entity.task_function, ClassDecorator): + extra_config = entity.task_function.get_extra_config() + + merged_config = {**entity_config, **extra_config} + + tt = TaskTemplate( + id=task_id, + type=entity.task_type, + metadata=entity.metadata.to_taskmetadata_model(), + interface=entity.interface, + custom=entity.get_custom(settings), + container=container, + task_type_version=entity.task_type_version, + security_context=entity.security_context, + config=merged_config, + k8s_pod=pod, + sql=entity.get_sql(settings), + extended_resources=entity.get_extended_resources(settings), + ) + if settings.should_fast_serialize() and isinstance(entity, PythonAutoContainerTask): + entity.reset_command_fn() + + return TaskSpec(template=tt, docs=entity.docs) + + +def get_serializable_workflow( + entity_mapping: OrderedDict, + settings: SerializationSettings, + entity: WorkflowBase, + options: Optional[Options] = None, +) -> admin_workflow_models.WorkflowSpec: + # Serialize all nodes + serialized_nodes = [] + sub_wfs = [] + for n in entity.nodes: + # Ignore start nodes + if n.id == _common_constants.GLOBAL_INPUT_NODE_ID: + continue + + # Recursively serialize the node + serialized_nodes.append(get_serializable(entity_mapping, settings, n, options)) + + # If the node is workflow Node or Branch node, we need to handle it specially, to extract all subworkflows, + # so that they can be added to the workflow being serialized + if isinstance(n.flyte_entity, WorkflowBase): + # We are currently not supporting reference workflows since these will + # require a network call to flyteadmin to populate the WorkflowTemplate + # object + if isinstance(n.flyte_entity, ReferenceWorkflow): + raise Exception( + "Reference sub-workflows are currently unsupported. Use reference launch plans instead." + ) + sub_wf_spec = get_serializable(entity_mapping, settings, n.flyte_entity, options) + if not isinstance(sub_wf_spec, admin_workflow_models.WorkflowSpec): + raise TypeError( + f"Unexpected type for serialized form of workflow. Expected {admin_workflow_models.WorkflowSpec}, but got {type(sub_wf_spec)}" + ) + sub_wfs.append(sub_wf_spec.template) + sub_wfs.extend(sub_wf_spec.sub_workflows) + + from flytekit.remote import FlyteWorkflow + + if isinstance(n.flyte_entity, FlyteWorkflow): + for swf in n.flyte_entity.flyte_sub_workflows: + sub_wf = get_serializable(entity_mapping, settings, swf, options) + sub_wfs.append(sub_wf.template) + main_wf = get_serializable(entity_mapping, settings, n.flyte_entity, options) + sub_wfs.append(main_wf.template) + + if isinstance(n.flyte_entity, BranchNode): + if_else: workflow_model.IfElseBlock = n.flyte_entity._ifelse_block + # See comment in get_serializable_branch_node also. Again this is a List[Node] even though it's supposed + # to be a List[workflow_model.Node] + leaf_nodes: List[Node] = filter( # noqa + None, + [ + if_else.case.then_node, + *([] if if_else.other is None else [x.then_node for x in if_else.other]), + if_else.else_node, + ], + ) + for leaf_node in leaf_nodes: + if isinstance(leaf_node.flyte_entity, WorkflowBase): + sub_wf_spec = get_serializable(entity_mapping, settings, leaf_node.flyte_entity, options) + sub_wfs.append(sub_wf_spec.template) + sub_wfs.extend(sub_wf_spec.sub_workflows) + elif isinstance(leaf_node.flyte_entity, FlyteWorkflow): + get_serializable(entity_mapping, settings, leaf_node.flyte_entity, options) + sub_wfs.append(leaf_node.flyte_entity) + sub_wfs.extend([s for s in leaf_node.flyte_entity.sub_workflows.values()]) + + serialized_failure_node = None + if entity.failure_node: + serialized_failure_node = get_serializable(entity_mapping, settings, entity.failure_node, options) + if isinstance(entity.failure_node.flyte_entity, WorkflowBase): + sub_wf_spec = get_serializable(entity_mapping, settings, entity.failure_node.flyte_entity, options) + sub_wfs.append(sub_wf_spec.template) + sub_wfs.extend(sub_wf_spec.sub_workflows) + + wf_id = _identifier_model.Identifier( + resource_type=_identifier_model.ResourceType.WORKFLOW, + project=settings.project, + domain=settings.domain, + name=entity.name, + version=settings.version, + ) + wf_t = workflow_model.WorkflowTemplate( + id=wf_id, + metadata=entity.workflow_metadata.to_flyte_model(), + metadata_defaults=entity.workflow_metadata_defaults.to_flyte_model(), + interface=entity.interface, + nodes=serialized_nodes, + outputs=entity.output_bindings, + failure_node=serialized_failure_node, + ) + + return admin_workflow_models.WorkflowSpec( + template=wf_t, sub_workflows=sorted(set(sub_wfs), key=lambda x: x.short_string()), docs=entity.docs + ) + + +def get_serializable_launch_plan( + entity_mapping: OrderedDict, + settings: SerializationSettings, + entity: LaunchPlan, + recurse_downstream: bool = True, + options: Optional[Options] = None, +) -> _launch_plan_models.LaunchPlan: + """ + :param entity_mapping: + :param settings: + :param entity: + :param options: + :param recurse_downstream: This boolean indicate is wf for the entity should also be recursed to + :return: + """ + if recurse_downstream: + wf_spec = get_serializable(entity_mapping, settings, entity.workflow, options) + wf_id = wf_spec.template.id + else: + wf_id = _identifier_model.Identifier( + resource_type=_identifier_model.ResourceType.WORKFLOW, + project=settings.project, + domain=settings.domain, + name=entity.workflow.name, + version=settings.version, + ) + + if not options: + options = Options() + + if options and options.raw_output_data_config: + raw_prefix_config = options.raw_output_data_config + else: + raw_prefix_config = entity.raw_output_data_config or _common_models.RawOutputDataConfig("") + + lps = _launch_plan_models.LaunchPlanSpec( + workflow_id=wf_id, + entity_metadata=_launch_plan_models.LaunchPlanMetadata( + schedule=entity.schedule, + notifications=options.notifications or entity.notifications, + launch_conditions=entity.additional_metadata, + ), + default_inputs=entity.parameters, + fixed_inputs=entity.fixed_inputs, + labels=options.labels or entity.labels or _common_models.Labels({}), + annotations=options.annotations or entity.annotations or _common_models.Annotations({}), + auth_role=None, + raw_output_data_config=raw_prefix_config, + max_parallelism=options.max_parallelism or entity.max_parallelism, + security_context=options.security_context or entity.security_context, + ) + + lp_id = _identifier_model.Identifier( + resource_type=_identifier_model.ResourceType.LAUNCH_PLAN, + project=settings.project, + domain=settings.domain, + name=entity.name, + version=settings.version, + ) + lp_model = _launch_plan_models.LaunchPlan( + id=lp_id, + spec=lps, + closure=_launch_plan_models.LaunchPlanClosure( + state=None, + expected_inputs=interface_models.ParameterMap({}), + expected_outputs=interface_models.VariableMap({}), + ), + ) + + return lp_model + + +def get_serializable_node( + entity_mapping: OrderedDict, + settings: SerializationSettings, + entity: Node, + options: Optional[Options] = None, +) -> workflow_model.Node: + if entity.flyte_entity is None: + raise Exception(f"Node {entity.id} has no flyte entity") + + upstream_nodes = [ + get_serializable(entity_mapping, settings, n, options=options) + for n in entity.upstream_nodes + if n.id != _common_constants.GLOBAL_INPUT_NODE_ID + ] + + # Reference entities also inherit from the classes in the second if statement so address them first. + if isinstance(entity.flyte_entity, ReferenceEntity): + ref_spec = get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + ref_template = ref_spec.template + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + ) + if ref_template.resource_type == _identifier_model.ResourceType.TASK: + node_model._task_node = workflow_model.TaskNode(reference_id=ref_template.id) + elif ref_template.resource_type == _identifier_model.ResourceType.WORKFLOW: + node_model._workflow_node = workflow_model.WorkflowNode(sub_workflow_ref=ref_template.id) + elif ref_template.resource_type == _identifier_model.ResourceType.LAUNCH_PLAN: + node_model._workflow_node = workflow_model.WorkflowNode(launchplan_ref=ref_template.id) + else: + raise Exception( + f"Unexpected resource type for reference entity {entity.flyte_entity}: {ref_template.resource_type}" + ) + return node_model + + from flytekit.remote import FlyteLaunchPlan, FlyteTask, FlyteWorkflow + + if isinstance(entity.flyte_entity, ArrayNodeMapTask): + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + array_node=get_serializable_array_node(entity_mapping, settings, entity, options=options), + ) + # TODO: do I need this? + # if entity._aliases: + # node_model._output_aliases = entity._aliases + elif isinstance(entity.flyte_entity, PythonTask): + task_spec = get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + task_node=workflow_model.TaskNode( + reference_id=task_spec.template.id, + overrides=TaskNodeOverrides(resources=entity._resources, extended_resources=entity._extended_resources), + ), + ) + if entity._aliases: + node_model._output_aliases = entity._aliases + + elif isinstance(entity.flyte_entity, WorkflowBase): + wf_spec = get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + workflow_node=workflow_model.WorkflowNode(sub_workflow_ref=wf_spec.template.id), + ) + + elif isinstance(entity.flyte_entity, BranchNode): + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + branch_node=get_serializable(entity_mapping, settings, entity.flyte_entity, options=options), + ) + + elif isinstance(entity.flyte_entity, LaunchPlan): + lp_spec = get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + + # Node's inputs should not contain the data which is fixed input + node_input = [] + for b in entity.bindings: + if b.var not in entity.flyte_entity.fixed_inputs.literals: + node_input.append(b) + + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=node_input, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + workflow_node=workflow_model.WorkflowNode(launchplan_ref=lp_spec.id), + ) + + elif isinstance(entity.flyte_entity, Gate): + if entity.flyte_entity.sleep_duration: + gn = GateNode(sleep=SleepCondition(duration=entity.flyte_entity.sleep_duration)) + elif entity.flyte_entity.input_type: + output_name = list(entity.flyte_entity.python_interface.outputs.keys())[0] # should be o0 + gn = GateNode( + signal=SignalCondition( + entity.flyte_entity.name, type=entity.flyte_entity.literal_type, output_variable_name=output_name + ) + ) + else: + gn = GateNode(approve=ApproveCondition(entity.flyte_entity.name)) + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + gate_node=gn, + ) + + elif isinstance(entity.flyte_entity, FlyteTask): + # Recursive call doesn't do anything except put the entity on the map. + get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + task_node=workflow_model.TaskNode( + reference_id=entity.flyte_entity.id, + overrides=TaskNodeOverrides(resources=entity._resources, extended_resources=entity._extended_resources), + ), + ) + elif isinstance(entity.flyte_entity, FlyteWorkflow): + wf_spec = get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + for sub_wf in entity.flyte_entity.flyte_sub_workflows: + get_serializable(entity_mapping, settings, sub_wf, options=options) + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=entity.bindings, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + workflow_node=workflow_model.WorkflowNode(sub_workflow_ref=wf_spec.id), + ) + elif isinstance(entity.flyte_entity, FlyteLaunchPlan): + # Recursive call doesn't do anything except put the entity on the map. + get_serializable(entity_mapping, settings, entity.flyte_entity, options=options) + # Node's inputs should not contain the data which is fixed input + node_input = [] + for b in entity.bindings: + if b.var not in entity.flyte_entity.fixed_inputs.literals: + node_input.append(b) + + node_model = workflow_model.Node( + id=_dnsify(entity.id), + metadata=entity.metadata, + inputs=node_input, + upstream_node_ids=[n.id for n in upstream_nodes], + output_aliases=[], + workflow_node=workflow_model.WorkflowNode(launchplan_ref=entity.flyte_entity.id), + ) + else: + raise Exception(f"Node contained non-serializable entity {entity._flyte_entity}") + + return node_model + + +def get_serializable_array_node( + entity_mapping: OrderedDict, + settings: SerializationSettings, + node: Node, + options: Optional[Options] = None, +) -> ArrayNodeModel: + # TODO Add support for other flyte entities + entity = node.flyte_entity + task_spec = get_serializable(entity_mapping, settings, entity, options) + task_node = workflow_model.TaskNode( + reference_id=task_spec.template.id, + overrides=TaskNodeOverrides(resources=node._resources, extended_resources=node._extended_resources), + ) + node = workflow_model.Node( + id=entity.name, + metadata=entity.construct_node_metadata(), + inputs=node.bindings, + upstream_node_ids=[], + output_aliases=[], + task_node=task_node, + ) + return ArrayNodeModel( + node=node, + parallelism=entity.concurrency, + min_successes=entity.min_successes, + min_success_ratio=entity.min_success_ratio, + ) + + +def get_serializable_branch_node( + entity_mapping: OrderedDict, + settings: SerializationSettings, + entity: FlyteLocalEntity, + options: Optional[Options] = None, +) -> BranchNodeModel: + # We have to iterate through the blocks to convert the nodes from the internal Node type to the Node model type. + # This was done to avoid having to create our own IfElseBlock object (i.e. condition.py just uses the model + # directly) even though the node there is of the wrong type (our type instead of the model type). + # TODO this should be cleaned up instead of mutation, we probably should just create a new object + first = to_serializable_case(entity_mapping, settings, entity._ifelse_block.case, options) + other = to_serializable_cases(entity_mapping, settings, entity._ifelse_block.other, options) + else_node_model = None + if entity._ifelse_block.else_node: + else_node_model = get_serializable(entity_mapping, settings, entity._ifelse_block.else_node, options=options) + + return BranchNodeModel( + if_else=_core_wf.IfElseBlock( + case=first, other=other, else_node=else_node_model, error=entity._ifelse_block.error + ) + ) + + +def get_reference_spec( + entity_mapping: OrderedDict, settings: SerializationSettings, entity: ReferenceEntity +) -> ReferenceSpec: + template = ReferenceTemplate(entity.id, entity.reference.resource_type) + return ReferenceSpec(template) + + +def get_serializable_flyte_workflow( + entity: "FlyteWorkflow", settings: SerializationSettings +) -> FlyteControlPlaneEntity: + """ + TODO replace with deep copy + """ + + def _mutate_task_node(tn: workflow_model.TaskNode): + tn.reference_id._project = settings.project + tn.reference_id._domain = settings.domain + + def _mutate_branch_node_task_ids(bn: workflow_model.BranchNode): + _mutate_node(bn.if_else.case.then_node) + for c in bn.if_else.other: + _mutate_node(c.then_node) + if bn.if_else.else_node: + _mutate_node(bn.if_else.else_node) + + def _mutate_workflow_node(wn: workflow_model.WorkflowNode): + wn.sub_workflow_ref._project = settings.project + wn.sub_workflow_ref._domain = settings.domain + + def _mutate_node(n: workflow_model.Node): + if n.task_node: + _mutate_task_node(n.task_node) + elif n.branch_node: + _mutate_branch_node_task_ids(n.branch_node) + elif n.workflow_node: + _mutate_workflow_node(n.workflow_node) + + for n in entity.flyte_nodes: + _mutate_node(n) + + entity.id._project = settings.project + entity.id._domain = settings.domain + + return entity + + +def get_serializable_flyte_task(entity: "FlyteTask", settings: SerializationSettings) -> FlyteControlPlaneEntity: + """ + TODO replace with deep copy + """ + entity.id._project = settings.project + entity.id._domain = settings.domain + return entity + + +def get_serializable( + entity_mapping: OrderedDict, + settings: SerializationSettings, + entity: FlyteLocalEntity, + options: Optional[Options] = None, +) -> FlyteControlPlaneEntity: + """ + The flytekit authoring code produces objects representing Flyte entities (tasks, workflows, etc.). In order to + register these, they need to be converted into objects that Flyte Admin understands (the IDL objects basically, but + this function currently translates to the layer above (e.g. SdkTask) - this will be changed to the IDL objects + directly in the future). + + :param entity_mapping: This is an ordered dict that will be mutated in place. The reason this argument exists is + because there is a natural ordering to the entities at registration time. That is, underlying tasks have to be + registered before the workflows that use them. The recursive search done by this function and the functions + above form a natural topological sort, finding the dependent entities and adding them to this parameter before + the parent entity this function is called with. + :param settings: used to pick up project/domain/name - to be deprecated. + :param entity: The local flyte entity to try to convert (along with its dependencies) + :param options: Optionally pass in a set of options that can be used to add additional metadata for Launchplans + :return: The resulting control plane entity, in addition to being added to the mutable entity_mapping parameter + is also returned. + """ + if entity in entity_mapping: + return entity_mapping[entity] + + from flytekit.remote import FlyteLaunchPlan, FlyteTask, FlyteWorkflow + + if isinstance(entity, ReferenceEntity): + cp_entity = get_reference_spec(entity_mapping, settings, entity) + + elif isinstance(entity, PythonTask): + cp_entity = get_serializable_task(entity_mapping, settings, entity) + + elif isinstance(entity, WorkflowBase): + cp_entity = get_serializable_workflow(entity_mapping, settings, entity, options) + + elif isinstance(entity, Node): + cp_entity = get_serializable_node(entity_mapping, settings, entity, options) + + elif isinstance(entity, LaunchPlan): + cp_entity = get_serializable_launch_plan(entity_mapping, settings, entity, options=options) + + elif isinstance(entity, BranchNode): + cp_entity = get_serializable_branch_node(entity_mapping, settings, entity, options) + + elif isinstance(entity, FlyteTask) or isinstance(entity, FlyteWorkflow): + if entity.should_register: + if isinstance(entity, FlyteTask): + cp_entity = get_serializable_flyte_task(entity, settings) + else: + if entity.should_register: + # We only add the tasks if the should register flag is set. This is to avoid adding + # unnecessary tasks to the registrable list. + for t in entity.flyte_tasks: + get_serializable(entity_mapping, settings, t, options) + cp_entity = get_serializable_flyte_workflow(entity, settings) + else: + cp_entity = entity + + elif isinstance(entity, FlyteLaunchPlan): + cp_entity = entity + + else: + raise Exception(f"Non serializable type found {type(entity)} Entity {entity}") + + if isinstance(entity, TaskSpec) or isinstance(entity, WorkflowSpec): + # 1. Check if the size of long description exceeds 16KB + # 2. Extract the repo URL from the git config, and assign it to the link of the source code of the description entity + if entity.docs and entity.docs.long_description: + if entity.docs.long_description.value: + if sys.getsizeof(entity.docs.long_description.value) > 16 * 1024 * 1024: + raise ValueError( + "Long Description of the flyte entity exceeds the 16KB size limit. Please specify the uri in the long description instead." + ) + entity.docs.source_code = SourceCode(link=settings.git_repo) + # This needs to be at the bottom not the top - i.e. dependent tasks get added before the workflow containing it + entity_mapping[entity] = cp_entity + return cp_entity + + +def gather_dependent_entities( + serialized: OrderedDict, +) -> Tuple[ + Dict[_identifier_model.Identifier, TaskTemplate], + Dict[_identifier_model.Identifier, admin_workflow_models.WorkflowSpec], + Dict[_identifier_model.Identifier, _launch_plan_models.LaunchPlanSpec], +]: + """ + The ``get_serializable`` function above takes in an ``OrderedDict`` that helps keep track of dependent entities. + For example, when serializing a workflow, all its tasks are also serialized. The ordered dict will also contain + serialized entities that aren't as useful though, like nodes and branches. This is just a small helper function + that will pull out the serialized tasks, workflows, and launch plans. This function is primarily used for testing. + + :param serialized: This should be the filled in OrderedDict used in the get_serializable function above. + :return: + """ + task_templates: Dict[_identifier_model.Identifier, TaskTemplate] = {} + workflow_specs: Dict[_identifier_model.Identifier, admin_workflow_models.WorkflowSpec] = {} + launch_plan_specs: Dict[_identifier_model.Identifier, _launch_plan_models.LaunchPlanSpec] = {} + + for cp_entity in serialized.values(): + if isinstance(cp_entity, TaskSpec): + task_templates[cp_entity.template.id] = cp_entity.template + elif isinstance(cp_entity, _launch_plan_models.LaunchPlan): + launch_plan_specs[cp_entity.id] = cp_entity.spec + elif isinstance(cp_entity, admin_workflow_models.WorkflowSpec): + workflow_specs[cp_entity.template.id] = cp_entity + + return task_templates, workflow_specs, launch_plan_specs diff --git a/flytekit/flytekit/types/__init__.py b/flytekit/flytekit/types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/flytekit/types/directory/__init__.py b/flytekit/flytekit/types/directory/__init__.py new file mode 100644 index 0000000000..87b494d0ae --- /dev/null +++ b/flytekit/flytekit/types/directory/__init__.py @@ -0,0 +1,37 @@ +""" +Flytekit Directory Type +========================================================== +.. currentmodule:: flytekit.types.directory + +Similar to :py:class:`flytekit.types.file.FlyteFile` there are some 'preformatted' directory types. + +.. autosummary:: + :toctree: generated/ + :template: file_types.rst + + FlyteDirectory + TensorboardLogs + TFRecordsDirectory +""" + +import typing + +from .types import FlyteDirectory + +# The following section provides some predefined aliases for commonly used FlyteDirectory formats. + +tensorboard = typing.TypeVar("tensorboard") +TensorboardLogs = FlyteDirectory[tensorboard] +""" + This type can be used to denote that the output is a folder that contains logs that can be loaded in TensorBoard. + This is usually the SummaryWriter output in PyTorch or Keras callbacks which record the history readable by + TensorBoard. +""" + +tfrecords_dir = typing.TypeVar("tfrecords_dir") +TFRecordsDirectory = FlyteDirectory[tfrecords_dir] +""" + This type can be used to denote that the output is a folder that contains tensorflow record files. + This is usually the TFRecordWriter output in Tensorflow which writes serialised tf.train.Example + message (or protobuf) to tfrecord files +""" diff --git a/flytekit/flytekit/types/directory/types.py b/flytekit/flytekit/types/directory/types.py new file mode 100644 index 0000000000..decd86c7c8 --- /dev/null +++ b/flytekit/flytekit/types/directory/types.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +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 DataClassJsonMixin, config +from fsspec.utils import get_protocol +from marshmallow import fields + +from flytekit.core.context_manager import FlyteContext, FlyteContextManager +from flytekit.core.type_engine import TypeEngine, TypeTransformer, get_batch_size +from flytekit.exceptions.user import FlyteAssertion +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, FlyteFile + +T = typing.TypeVar("T") +PathType = typing.Union[str, os.PathLike] + + +def noop(): + ... + + +@dataclass +class FlyteDirectory(DataClassJsonMixin, os.PathLike, typing.Generic[T]): + path: PathType = field(default=None, metadata=config(mm_field=fields.String())) # type: ignore + """ + .. warning:: + + This class should not be used on very large datasets, as merely listing the dataset will cause + the entire dataset to be downloaded. Listing on S3 and other backend object stores is not consistent + and we should not need data to be downloaded to list. + + Please first read through the comments on the :py:class:`flytekit.types.file.FlyteFile` class as the + implementation here is similar. + + One thing to note is that the ``os.PathLike`` type that comes with Python was used as a stand-in for ``FlyteFile``. + That is, if a task's output signature is an ``os.PathLike``, Flyte takes that to mean ``FlyteFile``. There is no + easy way to distinguish an ``os.PathLike`` where the user means a File and where the user means a Directory. As + such, if you want to use a directory, you must declare all types as ``FlyteDirectory``. You'll still be able to + return a string literal though instead of a full-fledged ``FlyteDirectory`` object assuming the str is a directory. + + **Converting from a Flyte literal value to a Python instance of FlyteDirectory** + + +-----------------------------+------------------------------------------------------------------------------------+ + | Type of Flyte IDL Literal | FlyteDirectory | + +=============+===============+====================================================================================+ + | Multipart | uri matches | FlyteDirectory object stores the original string | + | Blob | http(s)/s3/gs | path, but points to a local file instead. | + | | | | + | | | * [fn] downloader: function that writes to path when open'ed. | + | | | * [fn] download: will trigger download | + | | | * path: randomly generated local path that will not exist until downloaded | + | | | * remote_path: None | + | | | * remote_source: original http/s3/gs path | + | | | | + | +---------------+------------------------------------------------------------------------------------+ + | | uri matches | FlyteDirectory object just wraps the string | + | | /local/path | | + | | | * [fn] downloader: noop function | + | | | * [fn] download: raises exception | + | | | * path: just the given path | + | | | * remote_path: None | + | | | * remote_source: None | + +-------------+---------------+------------------------------------------------------------------------------------+ + + ----------- + + **Converting from a Python value (FlyteDirectory, str, or pathlib.Path) to a Flyte literal** + + +-----------------------------------+------------------------------------------------------------------------------+ + | Type of Python value | FlyteDirectory | + +===================+===============+==============================================================================+ + | str or | path matches | Blob object is returned with uri set to the given path. | + | pathlib.Path or | http(s)/s3/gs | Nothing is uploaded. | + | FlyteDirectory +---------------+------------------------------------------------------------------------------+ + | | path matches | Contents of file are uploaded to the Flyte blob store (S3, GCS, etc.), in | + | | /local/path | a bucket determined by the raw_output_data_prefix setting. If | + | | | remote_path is given, then that is used instead of the random path. Blob | + | | | object is returned with uri pointing to the blob store location. | + | | | | + +-------------------+---------------+------------------------------------------------------------------------------+ + + As inputs :: + + def t1(in1: FlyteDirectory): + ... + + def t1(in1: FlyteDirectory["svg"]): + ... + + As outputs: + + The contents of this local directory will be uploaded to the Flyte store. :: + + return FlyteDirectory("/path/to/dir/") + + return FlyteDirectory["svg"]("/path/to/dir/", remote_path="s3://special/output/location") + + Similar to the FlyteFile example, if you give an already remote location, it will not be copied to Flyte's + durable store, the uri will just be stored as is. :: + + return FlyteDirectory("s3://some/other/folder") + + Note if you write a path starting with http/s, if anything ever tries to read it (i.e. use the literal + as an input, it'll fail because the http proxy doesn't know how to download whole directories. + + The format [] bit is still there because in Flyte, directories are stored as Blob Types also, just like files, and + the Blob type has the format field. The difference in the type field is represented in the ``dimensionality`` + field in the ``BlobType``. + """ + + def __init__( + self, + path: typing.Union[str, os.PathLike], + downloader: typing.Optional[typing.Callable] = None, + remote_directory: typing.Optional[typing.Union[os.PathLike, str, typing.Literal[False]]] = None, + ): + """ + :param path: The source path that users are expected to call open() on + :param downloader: Optional function that can be passed that used to delay downloading of the actual fil + until a user actually calls open(). + :param remote_directory: If the user wants to return something and also specify where it should be uploaded to. + If set to False, then flytekit will not upload the directory to the remote store. + """ + # Make this field public, so that the dataclass transformer can set a value for it + # https://github.com/flyteorg/flytekit/blob/bcc8541bd6227b532f8462563fe8aac902242b21/flytekit/core/type_engine.py#L298 + self.path = path + self._downloader = downloader or noop + self._downloaded = False + self._remote_directory = remote_directory + self._remote_source: typing.Optional[str] = None + + def __fspath__(self): + """ + This function should be called by os.listdir as well. + """ + if not self._downloaded: + self._downloader() + self._downloaded = True + return self.path + + @classmethod + 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. + """ + ctx = FlyteContextManager.current_context() + r = ctx.file_access.get_random_string() + d = ctx.file_access.join(ctx.file_access.raw_output_prefix, r) + return FlyteDirectory(path=d) + + def __class_getitem__(cls, item: typing.Union[typing.Type, str]) -> typing.Type[FlyteDirectory]: + if item is None: + return cls + + item_string = FileExt.check_and_convert_to_str(item) + + item_string = item_string.strip().lstrip("~").lstrip(".") + if item_string == "": + return cls + + class _SpecificFormatDirectoryClass(FlyteDirectory): + # Get the type engine to see this as kind of a generic + __origin__ = FlyteDirectory + + @classmethod + def extension(cls) -> str: + return item_string + + return _SpecificFormatDirectoryClass + + @property + def downloaded(self) -> bool: + return self._downloaded + + @property + def remote_directory(self) -> typing.Optional[typing.Union[os.PathLike, bool, 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: + """ + If this is an input to a task, and the original path is s3://something, flytekit will download the + directory for the user. In case the user wants access to the original path, it will be here. + """ + 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__() + + @classmethod + def listdir(cls, directory: FlyteDirectory) -> typing.List[typing.Union[FlyteDirectory, FlyteFile]]: + """ + This function will list all files and folders in the given directory, but without downloading the contents. + In addition, it will return a list of FlyteFile and FlyteDirectory objects that have ability to lazily download the + contents of the file/folder. For example: + + .. code-block:: python + + entity = FlyteDirectory.listdir(directory) + for e in entity: + print("s3 object:", e.remote_source) + # s3 object: s3://test-flytedir/file1.txt + # s3 object: s3://test-flytedir/file2.txt + # s3 object: s3://test-flytedir/sub_dir + + open(entity[0], "r") # This will download the file to the local disk. + open(entity[0], "r") # flytekit will read data from the local disk if you open it again. + """ + + final_path = directory.path + if directory.remote_source: + final_path = directory.remote_source + elif directory.remote_directory: + final_path = typing.cast(os.PathLike, directory.remote_directory) + + paths: typing.List[typing.Union[FlyteDirectory, FlyteFile]] = [] + file_access = FlyteContextManager.current_context().file_access + if not file_access.is_remote(final_path): + for p in os.listdir(final_path): + if os.path.isfile(os.path.join(final_path, p)): + paths.append(FlyteFile(p)) + else: + paths.append(FlyteDirectory(p)) + return paths + + def create_downloader(_remote_path: str, _local_path: str, is_multipart: bool): + return lambda: file_access.get_data(_remote_path, _local_path, is_multipart=is_multipart) + + fs = file_access.get_filesystem_for_path(final_path) + for key in fs.listdir(final_path): + remote_path = os.path.join(final_path, key["name"].split(os.sep)[-1]) + if key["type"] == "file": + local_path = file_access.get_random_local_path() + os.makedirs(pathlib.Path(local_path).parent, exist_ok=True) + downloader = create_downloader(remote_path, local_path, is_multipart=False) + + flyte_file: FlyteFile = FlyteFile(local_path, downloader=downloader) + flyte_file._remote_source = remote_path + paths.append(flyte_file) + else: + local_folder = file_access.get_random_local_directory() + downloader = create_downloader(remote_path, local_folder, is_multipart=True) + + flyte_directory: FlyteDirectory = FlyteDirectory(path=local_folder, downloader=downloader) + flyte_directory._remote_source = remote_path + paths.append(flyte_directory) + + return paths + + 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 = typing.cast(os.PathLike, 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 str(self.path) + + def __str__(self): + return str(self.path) + + +class FlyteDirToMultipartBlobTransformer(TypeTransformer[FlyteDirectory]): + """ + This transformer handles conversion between the Python native FlyteDirectory class defined above, and the Flyte + IDL literal/type of Multipart Blob. Please see the FlyteDirectory comments for additional information. + + .. caution: + + The transformer will not check if the given path is actually a directory. This is because the path could be + a remote reference. + + """ + + def __init__(self): + super().__init__(name="FlyteDirectory", t=FlyteDirectory) + + @staticmethod + def get_format(t: typing.Type[FlyteDirectory]) -> str: + return t.extension() + + @staticmethod + def _blob_type(format: str) -> _core_types.BlobType: + return _core_types.BlobType(format=format, dimensionality=_core_types.BlobType.BlobDimensionality.MULTIPART) + + def assert_type(self, t: typing.Type[FlyteDirectory], v: typing.Union[FlyteDirectory, os.PathLike, str]): + if isinstance(v, FlyteDirectory) or isinstance(v, str) or isinstance(v, os.PathLike): + """ + NOTE: we do not do a isdir check because the given path could be remote reference + """ + return + raise TypeError( + f"No automatic conversion from {type(v)} declared type {t} to FlyteDirectory found." + f" Use (FlyteDirectory, str, os.PathLike)" + ) + + def get_literal_type(self, t: typing.Type[FlyteDirectory]) -> LiteralType: + return _type_models.LiteralType(blob=self._blob_type(format=FlyteDirToMultipartBlobTransformer.get_format(t))) + + def to_literal( + self, + ctx: FlyteContext, + python_val: FlyteDirectory, + python_type: typing.Type[FlyteDirectory], + expected: LiteralType, + ) -> Literal: + remote_directory = None + should_upload = True + batch_size = get_batch_size(python_type) + + meta = BlobMetadata(type=self._blob_type(format=self.get_format(python_type))) + + # There are two kinds of literals we handle, either an actual FlyteDirectory, or a string path to a directory. + # Handle the FlyteDirectory case + if isinstance(python_val, FlyteDirectory): + # If the object has a remote source, then we just convert it back. + if python_val._remote_source is not None: + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=python_val._remote_source))) + + source_path = str(python_val.path) + # If the user supplied a pathlike value, then the directory does need to be uploaded. However, don't upload + # the directory in the following circumstances: + # - If the user specified the remote_directory to be False. + # - If the path given is already a remote path, say https://www.google.com, uploading the Flyte + # blob store doesn't make sense. + if not isinstance(python_val.remote_directory, (pathlib.Path, str)) and ( + python_val.remote_directory is False + or ctx.file_access.is_remote(source_path) + or ctx.execution_state.is_local_execution() + ): + should_upload = False + + # Set the remote destination if one was given instead of triggering a random one below + remote_directory = python_val.remote_directory or None + + # Handle the string case + elif isinstance(python_val, (pathlib.Path, str)): + source_path = str(python_val) + + if ctx.file_access.is_remote(source_path): + should_upload = False + else: + p = Path(source_path) + if not p.is_dir(): + raise ValueError(f"Expected a directory. {source_path} is not a directory") + else: + raise AssertionError(f"Expected FlyteDirectory or os.PathLike object, received {type(python_val)}") + + # If we're uploading something, that means that the uri should always point to the upload destination. + if should_upload: + if remote_directory is None: + remote_directory = ctx.file_access.get_random_remote_directory() + if not pathlib.Path(source_path).is_dir(): + raise FlyteAssertion("Expected a directory. {} is not a directory".format(source_path)) + ctx.file_access.put_data(source_path, remote_directory, is_multipart=True, batch_size=batch_size) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_directory))) + + # If not uploading, then we can only take the original source path as the uri. + else: + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=source_path))) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: typing.Type[FlyteDirectory] + ) -> FlyteDirectory: + uri = lv.scalar.blob.uri + if not ctx.file_access.is_remote(uri) and not os.path.isdir(uri): + raise FlyteAssertion(f"Expected a directory, but the given uri '{uri}' is not a directory.") + + # This is a local file path, like /usr/local/my_dir, don't mess with it. Certainly, downloading it doesn't + # make any sense. + if not ctx.file_access.is_remote(uri): + return expected_python_type(uri, remote_directory=False) + + # For the remote case, return a FlyteDirectory object that can download + local_folder = ctx.file_access.get_random_local_directory() + + batch_size = get_batch_size(expected_python_type) + + def _downloader(): + return ctx.file_access.get_data(uri, local_folder, is_multipart=True, batch_size=batch_size) + + expected_format = self.get_format(expected_python_type) + + fd = FlyteDirectory.__class_getitem__(expected_format)(local_folder, _downloader) + fd._remote_source = uri + return fd + + def guess_python_type(self, literal_type: LiteralType) -> typing.Type[FlyteDirectory[typing.Any]]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.MULTIPART + ): + return FlyteDirectory.__class_getitem__(literal_type.blob.format) + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(FlyteDirToMultipartBlobTransformer()) diff --git a/flytekit/flytekit/types/error/__init__.py b/flytekit/flytekit/types/error/__init__.py new file mode 100644 index 0000000000..16ff444e63 --- /dev/null +++ b/flytekit/flytekit/types/error/__init__.py @@ -0,0 +1,12 @@ +""" +Flytekit Error Type +========================================================== +.. currentmodule:: flytekit.types.error + +.. autosummary:: + :toctree: generated/ + + FlyteError +""" + +from .error import FlyteError diff --git a/flytekit/flytekit/types/error/error.py b/flytekit/flytekit/types/error/error.py new file mode 100644 index 0000000000..fae719fed9 --- /dev/null +++ b/flytekit/flytekit/types/error/error.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from typing import Type, TypeVar + +from mashumaro.mixins.json import DataClassJSONMixin + +from flytekit.core.context_manager import FlyteContext +from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError +from flytekit.models import types as _type_models +from flytekit.models.literals import Error, Literal, Scalar +from flytekit.models.types import LiteralType + +T = TypeVar("T") + + +@dataclass +class FlyteError(DataClassJSONMixin): + """ + Special Task type that will be used in the failure node. Propeller will pass this error to failure task, so users + have to add an input with this type to the failure task. + """ + + message: str + failed_node_id: str + + +class ErrorTransformer(TypeTransformer[FlyteError]): + """ + Enables converting a python type FlyteError to LiteralType.Error + """ + + def __init__(self): + super().__init__(name="FlyteError", t=FlyteError) + + def get_literal_type(self, t: Type[T]) -> LiteralType: + return LiteralType(simple=_type_models.SimpleType.ERROR) + + def to_literal( + self, ctx: FlyteContext, python_val: FlyteError, python_type: Type[T], expected: LiteralType + ) -> Literal: + if type(python_val) != FlyteError: + raise TypeTransformerFailedError( + f"Expected value of type {FlyteError} but got '{python_val}' of type {type(python_val)}" + ) + return Literal(scalar=Scalar(error=Error(message=python_val.message, failed_node_id=python_val.failed_node_id))) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + if not (lv and lv.scalar and lv.scalar.error is not None): + raise TypeTransformerFailedError("Can only convert a generic literal to FlyteError") + return FlyteError(message=lv.scalar.error.message, failed_node_id=lv.scalar.error.failed_node_id) + + def guess_python_type(self, literal_type: LiteralType) -> Type[FlyteError]: + if literal_type.simple and literal_type.simple == _type_models.SimpleType.ERROR: + return FlyteError + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(ErrorTransformer()) diff --git a/flytekit/flytekit/types/file/__init__.py b/flytekit/flytekit/types/file/__init__.py new file mode 100644 index 0000000000..8a2fe50b6c --- /dev/null +++ b/flytekit/flytekit/types/file/__init__.py @@ -0,0 +1,116 @@ +""" +Flytekit File Type +========================================================== +.. currentmodule:: flytekit.types.file + +This list also contains a bunch of pre-formatted :py:class:`flytekit.types.file.FlyteFile` types. + +.. autosummary:: + :toctree: generated/ + :template: file_types.rst + + FlyteFile + HDF5EncodedFile + HTMLPage + JoblibSerializedFile + JPEGImageFile + PDFFile + PNGImageFile + PythonPickledFile + PythonNotebook + SVGImageFile +""" +import typing + +from typing_extensions import Annotated, get_args, get_origin + +from .file import FlyteFile + + +class FileExt: + """ + Used for annotating file extension types of FlyteFile. + This is useful for extensions that have periods in them, e.g., "tar.gz". + + Example: + TAR_GZ = Annotated[str, FileExt("tar.gz")] + """ + + def __init__(self, ext: str): + self._ext = ext + + def __str__(self): + return self._ext + + def __repr__(self): + return self._ext + + @staticmethod + def check_and_convert_to_str(item: typing.Union[typing.Type, str]) -> str: + if get_origin(item) is not Annotated: + return str(item) + if get_args(item)[0] == str: + return str(get_args(item)[1]) + raise ValueError("Underlying type of File Extension must be of type ") + + +# The following section provides some predefined aliases for commonly used FlyteFile formats. +# This makes their usage extremely simple for the users. Please keep the list sorted. + +hdf5 = Annotated[str, FileExt("hdf5")] +#: This can be used to denote that the returned file is of type hdf5 and can be received by other tasks that +#: accept an hdf5 format. This is usually useful for serializing Tensorflow models +HDF5EncodedFile = FlyteFile[hdf5] + +html = Annotated[str, FileExt("html")] +#: Can be used to receive or return an HTMLPage. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +HTMLPage = FlyteFile[html] + +joblib = Annotated[str, FileExt("joblib")] +#: This File represents a file that was serialized using `joblib.dump` method can be loaded back using `joblib.load`. +JoblibSerializedFile = FlyteFile[joblib] + +jpeg = Annotated[str, FileExt("jpeg")] +#: Can be used to receive or return an JPEGImage. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +JPEGImageFile = FlyteFile[jpeg] + +pdf = Annotated[str, FileExt("pdf")] +#: Can be used to receive or return an PDFFile. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +PDFFile = FlyteFile[pdf] + +png = Annotated[str, FileExt("png")] +#: Can be used to receive or return an PNGImage. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +PNGImageFile = FlyteFile[png] + +python_pickle = Annotated[str, FileExt("python_pickle")] +#: This type can be used when a serialized Python pickled object is returned and shared between tasks. This only +#: adds metadata to the file in Flyte, but does not really carry any object information. +PythonPickledFile = FlyteFile[python_pickle] + +ipynb = Annotated[str, FileExt("ipynb")] +#: This type is used to identify a Python notebook file. +PythonNotebook = FlyteFile[ipynb] + +svg = Annotated[str, FileExt("svg")] +#: Can be used to receive or return an SVGImage. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +SVGImageFile = FlyteFile[svg] + +csv = Annotated[str, FileExt("csv")] +#: Can be used to receive or return a CSVFile. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +CSVFile = FlyteFile[csv] + +onnx = Annotated[str, FileExt("onnx")] +#: Can be used to receive or return an ONNXFile. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +ONNXFile = FlyteFile[onnx] + +tfrecords_file = Annotated[str, FileExt("tfrecord")] +#: Can be used to receive or return an TFRecordFile. The underlying type is a FlyteFile type. This is just a +#: decoration and useful for attaching content type information with the file and automatically documenting code. +TFRecordFile = FlyteFile[tfrecords_file] diff --git a/flytekit/flytekit/types/file/file.py b/flytekit/flytekit/types/file/file.py new file mode 100644 index 0000000000..06b0c44a87 --- /dev/null +++ b/flytekit/flytekit/types/file/file.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import mimetypes +import os +import pathlib +import typing +from contextlib import contextmanager +from dataclasses import dataclass, field + +from dataclasses_json import config +from marshmallow import fields +from mashumaro.mixins.json import DataClassJSONMixin + +from flytekit.core.context_manager import FlyteContext, FlyteContextManager +from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError, get_underlying_type +from flytekit.exceptions.user import FlyteAssertion +from flytekit.loggers import logger +from flytekit.models.core.types import BlobType +from flytekit.models.literals import Blob, BlobMetadata, Literal, Scalar +from flytekit.models.types import LiteralType +from flytekit.types.pickle.pickle import FlytePickleTransformer + + +def noop(): + ... + + +T = typing.TypeVar("T") + + +@dataclass +class FlyteFile(os.PathLike, typing.Generic[T], DataClassJSONMixin): + 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 + in or return a file. There is ``pathlib.Path`` of course, (which is usable in Flytekit as a return value, though + not a return type), but it made more sense to create a new type esp. since we can add on additional properties. + + Files (and directories) differ from the primitive types like floats and string in that Flytekit typically uploads + the contents of the files to the blob store connected with your Flyte installation. That is, the Python native + literal that represents a file is typically just the path to the file on the local filesystem. However in Flyte, + an instance of a file is represented by a :py:class:`Blob ` literal, + with the ``uri`` field set to the location in the Flyte blob store (AWS/GCS etc.). Take a look at the + :std:ref:`data handling doc ` for a deeper discussion. + + We decided to not support ``pathlib.Path`` as an input/output type because if you wanted the automatic + upload/download behavior, you should just use the ``FlyteFile`` type. If you do not, then a ``str`` works just as + well. + + The prefix for where uploads go is set by the raw output data prefix setting, which should be set at registration + time in the launch plan. See the option listed under ``flytectl register examples --help`` for more information. + If not set in the launch plan, then your Flyte backend will specify a default. This default is itself configurable + as well. Contact your Flyte platform administrators to change or ascertain the value. + + In short, if a task returns ``"/path/to/file"`` and the task's signature is set to return ``FlyteFile``, then the + contents of ``/path/to/file`` are uploaded. + + You can also make it so that the upload does not happen. There are different types of + task/workflow signatures. Keep in mind that in the backend, in Admin and in the blob store, there is only one type + that represents files, the :py:class:`Blob ` type. + + Whether the uploading happens or not, the behavior of the translation between Python native values and Flyte + literal values depends on a few attributes: + + * The declared Python type in the signature. These can be + * :class:`python:flytekit.FlyteFile` + * :class:`python:os.PathLike` + Note that ``os.PathLike`` is only a type in Python, you can't instantiate it. + * The type of the Python native value we're returning. These can be + * :py:class:`flytekit.FlyteFile` + * :py:class:`pathlib.Path` + * :py:class:`str` + * Whether the value being converted is a "remote" path or not. For instance, if a task returns a value of + "http://www.google.com" as a ``FlyteFile``, obviously it doesn't make sense for us to try to upload that to the + Flyte blob store. So no remote paths are uploaded. Flytekit considers a path remote if it starts with ``s3://``, + ``gs://``, ``http(s)://``, or even ``file://``. + + **Converting from a Flyte literal value to a Python instance of FlyteFile** + + +-------------+---------------+---------------------------------------------+--------------------------------------+ + | | | Expected Python type | + +-------------+---------------+---------------------------------------------+--------------------------------------+ + | Type of Flyte IDL Literal | FlyteFile | os.PathLike | + +=============+===============+=============================================+======================================+ + | Blob | uri matches | FlyteFile object stores the original string | | + | | http(s)/s3/gs | path, but points to a local file instead. | | + | | | | | + | | | * [fn] downloader: function that writes to | | + | | | path when open'ed. | | + | | | * [fn] download: will trigger | Basically this signals Flyte should | + | | | download | stay out of the way. You still get | + | | | * path: randomly generated local path that | a FlyteFile object (which implements | + | | | will not exist until downloaded | the os.PathLike interface) | + | | | * remote_path: None | | + | | | * remote_source: original http/s3/gs path | * [fn] downloader: noop function, | + | | | | even if it's http/s3/gs | + | +---------------+---------------------------------------------+ * [fn] download: raises | + | | uri matches | FlyteFile object just wraps the string | exception | + | | /local/path | | * path: just the given path | + | | | * [fn] downloader: noop function | * remote_path: None | + | | | * [fn] download: raises exception | * remote_source: None | + | | | * path: just the given path | | + | | | * remote_path: None | | + | | | * remote_source: None | | + +-------------+---------------+---------------------------------------------+--------------------------------------+ + + **Converting from a Python value (FlyteFile, str, or pathlib.Path) to a Flyte literal** + + +-------------+---------------+---------------------------------------------+--------------------------------------+ + | | | Expected Python type | + +-------------+---------------+---------------------------------------------+--------------------------------------+ + | Type of Python value | FlyteFile | os.PathLike | + +=============+===============+=============================================+======================================+ + | str or | path matches | Blob object is returned with uri set to the given path. No uploading happens. | + | pathlib.Path| http(s)/s3/gs | | + | +---------------+---------------------------------------------+--------------------------------------+ + | | path matches | Contents of file are uploaded to the Flyte | No warning is logged since only a | + | | /local/path | blob store (S3, GCS, etc.), in a bucket | string is given (as opposed to a | + | | | determined by the raw_output_data_prefix | FlyteFile). Blob object is returned | + | | | setting. | with uri set to just the given path. | + | | | Blob object is returned with uri pointing | No uploading happens. | + | | | to the blob store location. | | + | | | | | + +-------------+---------------+---------------------------------------------+--------------------------------------+ + | FlyteFile | path matches | Blob object is returned with uri set to the given path. | + | | http(s)/s3/gs | Nothing is uploaded. | + | +---------------+---------------------------------------------+--------------------------------------+ + | | path matches | Contents of file are uploaded to the Flyte | Warning is logged since you're | + | | /local/path | blob store (S3, GCS, etc.), in a bucket | passing a more complex object (a | + | | | determined by the raw_output_data_prefix | FlyteFile) and expecting a simpler | + | | | setting. If remote_path is given, then that | interface (os.PathLike). Blob object | + | | | is used instead of the random path. Blob | is returned with uri set to just the | + | | | object is returned with uri pointing to | given path. No uploading happens. | + | | | the blob store location. | | + | | | | | + +-------------+---------------+---------------------------------------------+--------------------------------------+ + + Since Flyte file types have a string embedded in it as part of the type, you can add a + format by specifying a string after the class like so. :: + + def t2() -> flytekit_typing.FlyteFile["csv"]: + return "/tmp/local_file.csv" + """ + + @classmethod + 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() + r = ctx.file_access.get_random_string() + remote_path = ctx.file_access.join(ctx.file_access.raw_output_prefix, r) + return cls(path=remote_path) + + def __class_getitem__(cls, item: typing.Union[str, typing.Type]) -> typing.Type[FlyteFile]: + from . import FileExt + + if item is None: + return cls + + item_string = FileExt.check_and_convert_to_str(item) + + item_string = item_string.strip().lstrip("~").lstrip(".") + if item == "": + return cls + + class _SpecificFormatClass(FlyteFile): + # Get the type engine to see this as kind of a generic + __origin__ = FlyteFile + + @classmethod + def extension(cls) -> str: + return item_string + + return _SpecificFormatClass + + def __init__( + self, + path: typing.Union[str, os.PathLike], + downloader: typing.Callable = noop, + remote_path: typing.Optional[typing.Union[os.PathLike, bool]] = None, + ): + """ + FlyteFile's init method. + + :param path: The source path that users are expected to call open() on. + :param downloader: Optional function that can be passed that used to delay downloading of the actual fil + until a user actually calls open(). + :param remote_path: If the user wants to return something and also specify where it should be uploaded to. + Alternatively, if the user wants to specify a remote path for a file that's already in the blob store, + the path should point to the location and remote_path should be set to False. + """ + # Make this field public, so that the dataclass transformer can set a value for it + # https://github.com/flyteorg/flytekit/blob/bcc8541bd6227b532f8462563fe8aac902242b21/flytekit/core/type_engine.py#L298 + self.path = path + self._downloader = downloader + self._downloaded = False + self._remote_path = remote_path + self._remote_source: typing.Optional[str] = None + + def __fspath__(self): + # This is where a delayed downloading of the file will happen + if not self._downloaded: + self._downloader() + self._downloaded = True + return self.path + + def __eq__(self, other): + if isinstance(other, FlyteFile): + return ( + self.path == other.path + and self._remote_path == other._remote_path + and self.extension() == other.extension() + ) + else: + return self.path == other + + @property + def downloaded(self) -> bool: + return self._downloaded + + @property + def remote_path(self) -> typing.Optional[os.PathLike]: + # Find better ux for no-uploads in the future. + return self._remote_path # type: ignore + + @property + def remote_source(self) -> str: + """ + If this is an input to a task, and the original path is an ``s3`` bucket, Flytekit downloads the + file for the user. In case the user wants access to the original path, it will be here. + """ + return typing.cast(str, self._remote_source) + + 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 + + def __str__(self): + return self.path + + +class FlyteFilePathTransformer(TypeTransformer[FlyteFile]): + def __init__(self): + super().__init__(name="FlyteFilePath", t=FlyteFile) + + @staticmethod + def get_format(t: typing.Union[typing.Type[FlyteFile], os.PathLike]) -> str: + if t is os.PathLike: + return "" + return typing.cast(FlyteFile, t).extension() + + def _blob_type(self, format: str) -> BlobType: + return BlobType(format=format, dimensionality=BlobType.BlobDimensionality.SINGLE) + + def assert_type( + self, t: typing.Union[typing.Type[FlyteFile], os.PathLike], v: typing.Union[FlyteFile, os.PathLike, str] + ): + if isinstance(v, os.PathLike) or isinstance(v, FlyteFile) or isinstance(v, str): + return + raise TypeError( + f"No automatic conversion found from type {type(v)} to FlyteFile." + f"Supported (os.PathLike, str, Flytefile)" + ) + + def get_literal_type(self, t: typing.Union[typing.Type[FlyteFile], os.PathLike]) -> LiteralType: + return LiteralType(blob=self._blob_type(format=FlyteFilePathTransformer.get_format(t))) + + def get_mime_type_from_extension(self, extension: str) -> str: + extension_to_mime_type = { + "hdf5": "text/plain", + "joblib": "application/octet-stream", + "python_pickle": "application/octet-stream", + "ipynb": "application/json", + "onnx": "application/json", + "tfrecord": "application/octet-stream", + } + + for ext, mimetype in mimetypes.types_map.items(): + extension_to_mime_type[ext.split(".")[1]] = mimetype + + return extension_to_mime_type[extension] + + def validate_file_type( + self, python_type: typing.Type[FlyteFile], source_path: typing.Union[str, os.PathLike] + ) -> None: + """ + This method validates the type of the file at source_path against the expected python_type. + It uses the magic library to determine the real type of the file. If the magic library is not installed, + it logs a debug message and returns. If the actual file does not exist, it returns without raising an error. + + :param python_type: The expected type of the file + :param source_path: The path to the file to validate + :raises ValueError: If the real type of the file is not the same as the expected python_type + """ + if FlyteFilePathTransformer.get_format(python_type) == "": + return + + try: + # isolate the exception to the libmagic import + import magic + + except ImportError as e: + logger.debug(f"Libmagic is not installed. Error message: {e}") + return + + ctx = FlyteContext.current_context() + if ctx.file_access.is_remote(source_path): + # Skip validation for remote files. One of the use cases for FlyteFile is to point to remote files, + # you might have access to a remote file (e.g., in s3) that you want to pass to a Flyte workflow. + # Therefore, we should only validate FlyteFiles for which their path is considered local. + return + + if FlyteFilePathTransformer.get_format(python_type): + real_type = magic.from_file(source_path, mime=True) + expected_type = self.get_mime_type_from_extension(FlyteFilePathTransformer.get_format(python_type)) + if real_type != expected_type: + raise ValueError(f"Incorrect file type, expected {expected_type}, got {real_type}") + + def to_literal( + self, + ctx: FlyteContext, + python_val: typing.Union[FlyteFile, os.PathLike, str], + python_type: typing.Type[FlyteFile], + expected: LiteralType, + ) -> Literal: + remote_path = None + should_upload = True + + if python_val is None: + raise TypeTransformerFailedError("None value cannot be converted to a file.") + + # Correctly handle `Annotated[FlyteFile, ...]` by extracting the origin type + python_type = get_underlying_type(python_type) + + 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") + + # information used by all cases + meta = BlobMetadata(type=self._blob_type(format=FlyteFilePathTransformer.get_format(python_type))) + + if isinstance(python_val, FlyteFile): + source_path = python_val.path + self.validate_file_type(python_type, source_path) + + # If the object has a remote source, then we just convert it back. This means that if someone is just + # going back and forth between a FlyteFile Python value and a Blob Flyte IDL value, we don't do anything. + if python_val._remote_source is not None: + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=python_val._remote_source))) + + # If the user specified the remote_path to be False, that means no matter what, do not upload. Also if the + # path given is already a remote path, say https://www.google.com, the concept of uploading to the Flyte + # blob store doesn't make sense. + if python_val.remote_path is False or ctx.file_access.is_remote(source_path): + should_upload = False + # If the type that's given is a simpler type, we also don't upload, and print a warning too. + if python_type is os.PathLike: + logger.warning( + f"Converting from a FlyteFile Python instance to a Blob Flyte object, but only a {python_type} was" + f" specified. Since a simpler type was specified, we'll skip uploading!" + ) + should_upload = False + + # Set the remote destination if one was given instead of triggering a random one below + remote_path = python_val.remote_path or None + + elif isinstance(python_val, pathlib.Path) or isinstance(python_val, str): + source_path = str(python_val) + if issubclass(python_type, FlyteFile): + self.validate_file_type(python_type, source_path) + if ctx.file_access.is_remote(source_path): + should_upload = False + else: + if isinstance(python_val, pathlib.Path) and not python_val.is_file(): + raise ValueError(f"Error converting pathlib.Path {python_val} because it's not a file.") + + # If it's a string pointing to a local destination, then make sure it's a file. + if isinstance(python_val, str): + p = pathlib.Path(python_val) + if not p.is_file(): + raise TypeTransformerFailedError(f"Error converting {python_val} because it's not a file.") + # python_type must be os.PathLike - see check at beginning of function + else: + should_upload = False + + else: + raise TypeTransformerFailedError(f"Expected FlyteFile or os.PathLike object, received {type(python_val)}") + + # If we're uploading something, that means that the uri should always point to the upload destination. + if should_upload: + if remote_path is not None: + remote_path = ctx.file_access.put_data(source_path, remote_path, is_multipart=False) + else: + remote_path = ctx.file_access.put_raw_data(source_path) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + # If not uploading, then we can only take the original source path as the uri. + else: + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=source_path))) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: typing.Union[typing.Type[FlyteFile], os.PathLike] + ) -> FlyteFile: + try: + uri = lv.scalar.blob.uri + except AttributeError: + raise TypeTransformerFailedError(f"Cannot convert from {lv} to {expected_python_type}") + + if not ctx.file_access.is_remote(uri) and not os.path.isfile(uri): + raise FlyteAssertion( + f"Cannot convert from {lv} to {expected_python_type}. " f"Expected a file, but {uri} is not a file." + ) + + # In this condition, we still return a FlyteFile instance, but it's a simple one that has no downloading tricks + # Using is instead of issubclass because FlyteFile does actually subclass it + if expected_python_type is os.PathLike: + return FlyteFile(uri) + + # Correctly handle `Annotated[FlyteFile, ...]` by extracting the origin type + expected_python_type = get_underlying_type(expected_python_type) + + # The rest of the logic is only for FlyteFile types. + if not issubclass(expected_python_type, FlyteFile): # type: ignore + raise TypeError(f"Neither os.PathLike nor FlyteFile specified {expected_python_type}") + + # This is a local file path, like /usr/local/my_file, don't mess with it. Certainly, downloading it doesn't + # make any sense. + if not ctx.file_access.is_remote(uri): + return expected_python_type(uri) # type: ignore + + # For the remote case, return an FlyteFile object that can download + local_path = ctx.file_access.get_random_local_path(uri) + + def _downloader(): + return ctx.file_access.get_data(uri, local_path, is_multipart=False) + + expected_format = FlyteFilePathTransformer.get_format(expected_python_type) + ff = FlyteFile.__class_getitem__(expected_format)(local_path, _downloader) + ff._remote_source = uri + + return ff + + def guess_python_type(self, literal_type: LiteralType) -> typing.Type[FlyteFile[typing.Any]]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format != FlytePickleTransformer.PYTHON_PICKLE_FORMAT + ): + return FlyteFile.__class_getitem__(literal_type.blob.format) + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(FlyteFilePathTransformer(), additional_types=[os.PathLike]) diff --git a/flytekit/flytekit/types/file/image.py b/flytekit/flytekit/types/file/image.py new file mode 100644 index 0000000000..90b39d5c6f --- /dev/null +++ b/flytekit/flytekit/types/file/image.py @@ -0,0 +1,81 @@ +import pathlib +import typing +from typing import Type + +import PIL.Image + +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 + +T = typing.TypeVar("T") + + +class PILImageTransformer(TypeTransformer[T]): + """ + TypeTransformer that supports PIL.Image as a native type. + """ + + FILE_FORMAT = "PIL.Image" + + def __init__(self): + super().__init__(name="PIL.Image", t=PIL.Image.Image) + + def get_literal_type(self, t: Type[T]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.FILE_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + + def to_literal( + self, ctx: FlyteContext, python_val: PIL.Image.Image, python_type: Type[T], expected: LiteralType + ) -> Literal: + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.FILE_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + + local_path = ctx.file_access.get_random_local_path() + ".png" + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + python_val.save(local_path) + + remote_path = ctx.file_access.get_random_remote_path(local_path) + ctx.file_access.put_data(local_path, remote_path, is_multipart=False) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> PIL.Image.Image: + try: + uri = lv.scalar.blob.uri + except AttributeError: + raise 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=False) + + return PIL.Image.open(local_path) + + def guess_python_type(self, literal_type: LiteralType) -> Type[T]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.FILE_FORMAT + ): + return PIL.Image.Image + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + def to_html(self, ctx: FlyteContext, python_val: PIL.Image.Image, expected_python_type: Type[T]) -> str: + import base64 + from io import BytesIO + + buffered = BytesIO() + python_val.save(buffered, format="PNG") + img_base64 = base64.b64encode(buffered.getvalue()).decode() + return f'Rendered Image' + + +TypeEngine.register(PILImageTransformer()) diff --git a/flytekit/flytekit/types/iterator/__init__.py b/flytekit/flytekit/types/iterator/__init__.py new file mode 100644 index 0000000000..59733b62a0 --- /dev/null +++ b/flytekit/flytekit/types/iterator/__init__.py @@ -0,0 +1,10 @@ +""" +Flytekit Iterator Type +========================================================== +.. currentmodule:: flytekit.types.iterator +.. autosummary:: + :toctree: generated/ + FlyteIterator +""" + +from .iterator import FlyteIterator diff --git a/flytekit/flytekit/types/iterator/iterator.py b/flytekit/flytekit/types/iterator/iterator.py new file mode 100644 index 0000000000..cdd532f9b7 --- /dev/null +++ b/flytekit/flytekit/types/iterator/iterator.py @@ -0,0 +1,67 @@ +import collections +import typing + +from typing_extensions import get_args + +from flytekit import FlyteContext, Literal, LiteralType +from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError +from flytekit.models import types as _type_models +from flytekit.models.literals import LiteralCollection + +T = typing.TypeVar("T") + + +class FlyteIterator: + def __init__(self, ctx: FlyteContext, lv: Literal, expected_python_type: typing.Type[T], length: int): + self._ctx = ctx + self._lv = lv + self._expected_python_type = expected_python_type + self._length = length + self._index = 0 + + def __len__(self): + return self._length + + def __iter__(self): + self._index = 0 + return self + + def __next__(self): + if self._index < self._length: + lits = self._lv.collection.literals + st = get_args(self._expected_python_type)[0] + lt = TypeEngine.to_python_value(self._ctx, lits[self._index], st) + self._index += 1 + return lt + + else: + raise StopIteration + + +class IteratorTransformer(TypeTransformer[typing.Iterator]): + def __init__(self): + super().__init__("Typed Iterator", typing.Iterator) + + def get_literal_type(self, t: typing.Type[T]) -> typing.Optional[LiteralType]: + try: + sub_type = TypeEngine.to_literal_type(get_args(t)[0]) + return _type_models.LiteralType(collection_type=sub_type) + except Exception as e: + raise ValueError(f"Type of Generic List type is not supported, {e}") + + def to_literal( + self, ctx: FlyteContext, python_val: typing.Iterator[T], python_type: typing.Type[T], expected: LiteralType + ) -> Literal: + t = get_args(python_type)[0] + lit_list = [TypeEngine.to_literal(ctx, x, t, expected.collection_type) for x in python_val] + return Literal(collection=LiteralCollection(literals=lit_list)) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: typing.Type[T]) -> FlyteIterator: + try: + lits = lv.collection.literals + except AttributeError: + raise TypeTransformerFailedError() + return FlyteIterator(ctx, lv, expected_python_type, len(lits)) + + +TypeEngine.register(IteratorTransformer(), [collections.abc.Iterator]) diff --git a/flytekit/flytekit/types/numpy/__init__.py b/flytekit/flytekit/types/numpy/__init__.py new file mode 100644 index 0000000000..ec20e87970 --- /dev/null +++ b/flytekit/flytekit/types/numpy/__init__.py @@ -0,0 +1 @@ +from .ndarray import NumpyArrayTransformer diff --git a/flytekit/flytekit/types/numpy/ndarray.py b/flytekit/flytekit/types/numpy/ndarray.py new file mode 100644 index 0000000000..3455ea8267 --- /dev/null +++ b/flytekit/flytekit/types/numpy/ndarray.py @@ -0,0 +1,92 @@ +import pathlib +import typing +from collections import OrderedDict +from typing import Dict, Tuple, Type + +import numpy as np +from typing_extensions import Annotated, get_args, get_origin + +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 + + +def extract_metadata(t: Type[np.ndarray]) -> Tuple[Type[np.ndarray], Dict[str, bool]]: + metadata = {} + if get_origin(t) is Annotated: + base_type, metadata = get_args(t) + if isinstance(metadata, OrderedDict): + return base_type, metadata + else: + raise TypeTransformerFailedError(f"{t}'s metadata needs to be of type kwtypes.") + return t, metadata + + +class NumpyArrayTransformer(TypeTransformer[np.ndarray]): + """ + TypeTransformer that supports np.ndarray as a native type. + """ + + NUMPY_ARRAY_FORMAT = "NumpyArray" + + def __init__(self): + super().__init__(name="Numpy Array", t=np.ndarray) + + def get_literal_type(self, t: Type[np.ndarray]) -> LiteralType: + return LiteralType( + blob=_core_types.BlobType( + format=self.NUMPY_ARRAY_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + + def to_literal( + self, ctx: FlyteContext, python_val: np.ndarray, python_type: Type[np.ndarray], expected: LiteralType + ) -> Literal: + python_type, metadata = extract_metadata(python_type) + + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.NUMPY_ARRAY_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + + local_path = ctx.file_access.get_random_local_path() + ".npy" + pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True) + + # save numpy array to file + np.save(file=local_path, arr=python_val, allow_pickle=metadata.get("allow_pickle", False)) + remote_path = ctx.file_access.put_raw_data(local_path) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[np.ndarray]) -> np.ndarray: + try: + uri = lv.scalar.blob.uri + except AttributeError: + raise TypeTransformerFailedError(f"Cannot convert from {lv} to {expected_python_type}") + + expected_python_type, metadata = extract_metadata(expected_python_type) + + local_path = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(uri, local_path, is_multipart=False) + + # load numpy array from a file + return np.load( + file=local_path, + allow_pickle=metadata.get("allow_pickle", False), + mmap_mode=metadata.get("mmap_mode"), # type: ignore + ) + + def guess_python_type(self, literal_type: LiteralType) -> typing.Type[np.ndarray]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == self.NUMPY_ARRAY_FORMAT + ): + return np.ndarray + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + +TypeEngine.register(NumpyArrayTransformer()) diff --git a/flytekit/flytekit/types/pickle/__init__.py b/flytekit/flytekit/types/pickle/__init__.py new file mode 100644 index 0000000000..e5bd1c056d --- /dev/null +++ b/flytekit/flytekit/types/pickle/__init__.py @@ -0,0 +1,12 @@ +""" +Flytekit Pickle Type +========================================================== +.. currentmodule:: flytekit.types.pickle + +.. autosummary:: + :toctree: generated/ + + FlytePickle +""" + +from .pickle import BatchSize, FlytePickle diff --git a/flytekit/flytekit/types/pickle/pickle.py b/flytekit/flytekit/types/pickle/pickle.py new file mode 100644 index 0000000000..c4b8caf6f3 --- /dev/null +++ b/flytekit/flytekit/types/pickle/pickle.py @@ -0,0 +1,125 @@ +import os +import typing +from typing import Type + +import cloudpickle + +from flytekit.core.context_manager import FlyteContext, FlyteContextManager +from flytekit.core.type_engine import TypeEngine, TypeTransformer +from flytekit.models.core import types as _core_types +from flytekit.models.literals import Blob, BlobMetadata, Literal, Scalar +from flytekit.models.types import LiteralType + +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. + Any type that flyte can't recognize will become FlytePickle + """ + + @classmethod + def python_type(cls) -> typing.Type: + return type(None) + + @classmethod + def __class_getitem__(cls, python_type: typing.Type) -> typing.Type: + if python_type is None: + return cls + + class _SpecificFormatClass(FlytePickle): + # Get the type engine to see this as kind of a generic + __origin__ = FlytePickle + + @classmethod + def python_type(cls) -> typing.Type: + return python_type + + return _SpecificFormatClass + + @classmethod + def to_pickle(cls, python_val: typing.Any) -> str: + ctx = FlyteContextManager.current_context() + local_dir = ctx.file_access.get_random_local_directory() + os.makedirs(local_dir, exist_ok=True) + local_path = ctx.file_access.get_random_local_path() + uri = os.path.join(local_dir, local_path) + with open(uri, "w+b") as outfile: + cloudpickle.dump(python_val, outfile) + + return ctx.file_access.put_raw_data(uri) + + @classmethod + def from_pickle(cls, uri: str) -> typing.Any: + ctx = FlyteContextManager.current_context() + # Deserialize the pickle, and return data in the pickle, + # and download pickle file to local first if file is not in the local file systems. + if ctx.file_access.is_remote(uri): + local_path = ctx.file_access.get_random_local_path() + ctx.file_access.get_data(uri, local_path, False) + uri = local_path + with open(uri, "rb") as infile: + data = cloudpickle.load(infile) + return data + + +class FlytePickleTransformer(TypeTransformer[FlytePickle]): + PYTHON_PICKLE_FORMAT = "PythonPickle" + + def __init__(self): + super().__init__(name="FlytePickle", t=FlytePickle) + + def assert_type(self, t: Type[T], v: T): + # Every type can serialize to pickle, so we don't need to check the type here. + ... + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[T]) -> T: + uri = lv.scalar.blob.uri + return FlytePickle.from_pickle(uri) + + def to_literal(self, ctx: FlyteContext, python_val: T, python_type: Type[T], expected: LiteralType) -> Literal: + if python_val is None: + raise AssertionError("Cannot pickle None Value.") + meta = BlobMetadata( + type=_core_types.BlobType( + format=self.PYTHON_PICKLE_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + remote_path = FlytePickle.to_pickle(python_val) + return Literal(scalar=Scalar(blob=Blob(metadata=meta, uri=remote_path))) + + def guess_python_type(self, literal_type: LiteralType) -> typing.Type[FlytePickle[typing.Any]]: + if ( + literal_type.blob is not None + and literal_type.blob.dimensionality == _core_types.BlobType.BlobDimensionality.SINGLE + and literal_type.blob.format == FlytePickleTransformer.PYTHON_PICKLE_FORMAT + ): + return FlytePickle + + raise ValueError(f"Transformer {self} cannot reverse {literal_type}") + + def get_literal_type(self, t: Type[T]) -> LiteralType: + lt = LiteralType( + blob=_core_types.BlobType( + format=self.PYTHON_PICKLE_FORMAT, dimensionality=_core_types.BlobType.BlobDimensionality.SINGLE + ) + ) + lt.metadata = {"python_class_name": str(t)} + return lt + + +TypeEngine.register(FlytePickleTransformer()) diff --git a/flytekit/flytekit/types/schema/__init__.py b/flytekit/flytekit/types/schema/__init__.py new file mode 100644 index 0000000000..080927021a --- /dev/null +++ b/flytekit/flytekit/types/schema/__init__.py @@ -0,0 +1,11 @@ +from .types import ( + FlyteSchema, + LocalIOSchemaReader, + LocalIOSchemaWriter, + SchemaEngine, + SchemaFormat, + SchemaHandler, + SchemaOpenMode, + SchemaReader, + SchemaWriter, +) diff --git a/flytekit/flytekit/types/schema/types.py b/flytekit/flytekit/types/schema/types.py new file mode 100644 index 0000000000..fb3ad09d89 --- /dev/null +++ b/flytekit/flytekit/types/schema/types.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import datetime as _datetime +import os +import typing +from abc import abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Type + +import numpy as _np +from dataclasses_json import config +from marshmallow import fields +from mashumaro.mixins.json import DataClassJSONMixin + +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.literals import Literal, Scalar, Schema +from flytekit.models.types import LiteralType, SchemaType + +T = typing.TypeVar("T") + + +class SchemaFormat(Enum): + """ + Represents the schema storage format (at rest). + Currently only parquet is supported + """ + + PARQUET = "parquet" + # ARROW = "arrow" + # HDF5 = "hdf5" + # CSV = "csv" + # RECORDIO = "recordio" + + +class SchemaOpenMode(Enum): + READ = "r" + WRITE = "w" + + +def generate_ordered_files(directory: os.PathLike, n: int) -> typing.Generator[str, None, None]: + for i in range(n): + yield os.path.join(directory, f"{i:05}") + + +class SchemaReader(typing.Generic[T]): + """ + Base SchemaReader to handle any readers (that can manage their own IO or otherwise) + Use the simplified base LocalIOSchemaReader for non distributed dataframes + """ + + def __init__(self, from_path: str, cols: typing.Optional[typing.Dict[str, type]], fmt: SchemaFormat): + self._from_path = from_path + self._fmt = fmt + self._columns = cols + + @property + def from_path(self) -> str: + return self._from_path + + @property + def column_names(self) -> typing.Optional[typing.List[str]]: + if self._columns: + return list(self._columns.keys()) + return None + + @abstractmethod + def iter(self, **kwargs) -> typing.Generator[T, None, None]: + ... + + @abstractmethod + def all(self, **kwargs) -> T: + ... + + +class SchemaWriter(typing.Generic[T]): + def __init__(self, to_path: str, cols: typing.Optional[typing.Dict[str, type]], fmt: SchemaFormat): + self._to_path = to_path + self._fmt = fmt + self._columns = cols + # TODO This should be change to send a stop instead of hardcoded to 1024 + self._file_name_gen = generate_ordered_files(Path(self._to_path), 1024) + + @property + def to_path(self) -> str: + return self._to_path + + @property + def column_names(self) -> typing.Optional[typing.List[str]]: + if self._columns: + return list(self._columns.keys()) + return None + + @abstractmethod + def write(self, *dfs, **kwargs): + ... + + +class LocalIOSchemaReader(SchemaReader[T]): + def __init__(self, from_path: str, cols: typing.Optional[typing.Dict[str, type]], fmt: SchemaFormat): + super().__init__(from_path, cols, fmt) + + @abstractmethod + def _read(self, *path: os.PathLike, **kwargs) -> T: + pass + + def iter(self, **kwargs) -> typing.Generator[T, None, None]: + with os.scandir(self._from_path) as it: # type: ignore + for entry in it: + if ( + not typing.cast(os.DirEntry, entry).name.startswith(".") + and typing.cast(os.DirEntry, entry).is_file() + ): + yield self._read(Path(typing.cast(os.DirEntry, entry).path), **kwargs) + + def all(self, **kwargs) -> T: + files: typing.List[os.PathLike] = [] + with os.scandir(self._from_path) as it: # type: ignore + for entry in it: + if ( + not typing.cast(os.DirEntry, entry).name.startswith(".") + and typing.cast(os.DirEntry, entry).is_file() + ): + files.append(Path(typing.cast(os.DirEntry, entry).path)) + + return self._read(*files, **kwargs) + + +class LocalIOSchemaWriter(SchemaWriter[T]): + def __init__(self, to_local_path: str, cols: typing.Optional[typing.Dict[str, type]], fmt: SchemaFormat): + super().__init__(to_local_path, cols, fmt) + + @abstractmethod + def _write(self, df: T, path: os.PathLike, **kwargs): + pass + + def write(self, *dfs, **kwargs): + for df in dfs: + self._write(df, next(self._file_name_gen), **kwargs) + + +@dataclass +class SchemaHandler(object): + name: str + object_type: Type + reader: Type[SchemaReader] + writer: Type[SchemaWriter] + handles_remote_io: bool = False + + +class SchemaEngine(object): + """ + This is the core Engine that handles all schema sub-systems. All schema types needs to be registered with this + to allow direct support for that type in FlyteSchema. + e.g. of possible supported types are Pandas.DataFrame, Spark.DataFrame, Vaex.DataFrame, etc. + """ + + _SCHEMA_HANDLERS: typing.Dict[type, SchemaHandler] = {} + + @classmethod + def register_handler(cls, h: SchemaHandler): + """ + Register a new handler that can create a SchemaReader and SchemaWriter for the expected type. + """ + if h.object_type in cls._SCHEMA_HANDLERS: + raise ValueError( + f"SchemaHandler {cls._SCHEMA_HANDLERS[h.object_type].name} already registered for " + f"{h.object_type}, cannot replace with {h.name}" + ) + cls._SCHEMA_HANDLERS[h.object_type] = h + + @classmethod + def get_handler(cls, t: Type) -> SchemaHandler: + if t not in cls._SCHEMA_HANDLERS: + raise ValueError(f"DataFrames of type {t} are not supported currently") + return cls._SCHEMA_HANDLERS[t] + + +@dataclass +class FlyteSchema(DataClassJSONMixin): + remote_path: typing.Optional[str] = field(default=None, metadata=config(mm_field=fields.String())) + """ + This is the main schema class that users should use. + """ + + @classmethod + def columns(cls) -> typing.Dict[str, typing.Type]: + return {} + + @classmethod + def column_names(cls) -> typing.List[str]: + return [k for k, v in cls.columns().items()] + + @classmethod + def format(cls) -> SchemaFormat: + return SchemaFormat.PARQUET + + 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 + + if not isinstance(columns, dict): + raise AssertionError( + f"Columns should be specified as an ordered dict of column names and their types, received {type(columns)}" + ) + + if len(columns) == 0: + return FlyteSchema + + if not isinstance(fmt, SchemaFormat): + raise AssertionError( + f"Only FlyteSchemaFormat types are supported, received format is {fmt} of type {type(fmt)}" + ) + + class _TypedSchema(FlyteSchema): + # Get the type engine to see this as kind of a generic + __origin__ = FlyteSchema + + @classmethod + def columns(cls) -> typing.Dict[str, typing.Type]: + return columns + + @classmethod + def format(cls) -> SchemaFormat: + return fmt + + return _TypedSchema + + def __init__( + self, + local_path: typing.Optional[str] = None, + remote_path: typing.Optional[str] = None, + supported_mode: SchemaOpenMode = SchemaOpenMode.WRITE, + downloader: typing.Optional[typing.Callable] = None, + ): + logger.warning("FlyteSchema is deprecated, use Structured Dataset instead.") + if supported_mode == SchemaOpenMode.READ and remote_path is None: + raise ValueError("To create a FlyteSchema in read mode, remote_path is required") + if ( + supported_mode == SchemaOpenMode.WRITE + and local_path is None + and FlyteContextManager.current_context().file_access is None + ): + raise ValueError("To create a FlyteSchema in write mode, local_path is required") + + local_path = local_path or FlyteContextManager.current_context().file_access.get_random_local_directory() + self._local_path = local_path + # Make this field public, so that the dataclass transformer can set a value for it + # https://github.com/flyteorg/flytekit/blob/bcc8541bd6227b532f8462563fe8aac902242b21/flytekit/core/type_engine.py#L298 + fp = FlyteContextManager.current_context().file_access + self.remote_path = remote_path or fp.join(fp.raw_output_prefix, fp.get_random_string()) + self._supported_mode = supported_mode + # This is a special attribute that indicates if the data was either downloaded or uploaded + self._downloaded = False + self._downloader = downloader + + @property + def local_path(self) -> str: + return self._local_path + + @property + def supported_mode(self) -> SchemaOpenMode: + return self._supported_mode + + def open( + self, dataframe_fmt: typing.Optional[type] = None, override_mode: typing.Optional[SchemaOpenMode] = None + ) -> typing.Union[SchemaReader, SchemaWriter]: + """ + Returns a reader or writer depending on the mode of the object when created. This mode can be + overridden, but will depend on whether the override can be performed. For example, if the Object was + created in a read-mode a "write mode" override is not allowed. + if the object was created in write-mode, a read is allowed. + + :param dataframe_fmt: Type of the dataframe for example pandas.DataFrame etc + :param override_mode: overrides the default mode (Read, Write) SchemaOpenMode.READ, SchemaOpenMode.Write + So if you have written to a schema and want to re-open it for reading, you can use this + mode. A ReadOnly Schema object cannot be opened in write mode. + """ + if override_mode and self._supported_mode == SchemaOpenMode.READ and override_mode == SchemaOpenMode.WRITE: + raise AssertionError("Readonly schema cannot be opened in write mode!") + + mode = override_mode if override_mode else self._supported_mode + import pandas as pd + + dataframe_fmt = dataframe_fmt if dataframe_fmt else pd.DataFrame + h = SchemaEngine.get_handler(dataframe_fmt) + if not h.handles_remote_io: + # The Schema Handler does not manage its own IO, and this it will expect the files are on local file-system + if self._supported_mode == SchemaOpenMode.READ and not self._downloaded: + if self._downloader is None: + raise AssertionError("downloader cannot be None in read mode!") + # Only for readable objects if they are not downloaded already, we should download them + # Write objects should already have everything written to + self._downloader(self.remote_path, self.local_path) + self._downloaded = True + if mode == SchemaOpenMode.WRITE: + return h.writer(self.local_path, self.columns(), self.format()) + return h.reader(self.local_path, self.columns(), self.format()) + + # Remote IO is handled. So we will just pass the remote reference to the object + if mode == SchemaOpenMode.WRITE: + return h.writer(typing.cast(str, self.remote_path), self.columns(), self.format()) + return h.reader(typing.cast(str, self.remote_path), self.columns(), self.format()) + + def as_readonly(self) -> FlyteSchema: + if self._supported_mode == SchemaOpenMode.READ: + return self + s = FlyteSchema.__class_getitem__(self.columns(), self.format())( + local_path=self.local_path, + # Dummy path is ok, as we will assume data is already downloaded and will not download again + remote_path=typing.cast(str, self.remote_path) if self.remote_path else "", + supported_mode=SchemaOpenMode.READ, + ) + s._downloaded = True + return s + + +class FlyteSchemaTransformer(TypeTransformer[FlyteSchema]): + _SUPPORTED_TYPES: typing.Dict[Type, SchemaType.SchemaColumn.SchemaColumnType] = { + _np.int32: SchemaType.SchemaColumn.SchemaColumnType.INTEGER, + _np.int64: SchemaType.SchemaColumn.SchemaColumnType.INTEGER, + _np.uint32: SchemaType.SchemaColumn.SchemaColumnType.INTEGER, + _np.uint64: SchemaType.SchemaColumn.SchemaColumnType.INTEGER, + int: SchemaType.SchemaColumn.SchemaColumnType.INTEGER, + _np.float32: SchemaType.SchemaColumn.SchemaColumnType.FLOAT, + _np.float64: SchemaType.SchemaColumn.SchemaColumnType.FLOAT, + float: SchemaType.SchemaColumn.SchemaColumnType.FLOAT, + _np.bool_: SchemaType.SchemaColumn.SchemaColumnType.BOOLEAN, # type: ignore + bool: SchemaType.SchemaColumn.SchemaColumnType.BOOLEAN, + _np.datetime64: SchemaType.SchemaColumn.SchemaColumnType.DATETIME, + _datetime.datetime: SchemaType.SchemaColumn.SchemaColumnType.DATETIME, + _np.timedelta64: SchemaType.SchemaColumn.SchemaColumnType.DURATION, + _datetime.timedelta: SchemaType.SchemaColumn.SchemaColumnType.DURATION, + _np.string_: SchemaType.SchemaColumn.SchemaColumnType.STRING, + _np.str_: SchemaType.SchemaColumn.SchemaColumnType.STRING, + _np.object_: SchemaType.SchemaColumn.SchemaColumnType.STRING, + str: SchemaType.SchemaColumn.SchemaColumnType.STRING, + } + + def __init__(self): + super().__init__("FlyteSchema Transformer", FlyteSchema) + + def _get_schema_type(self, t: Type[FlyteSchema]) -> SchemaType: + converted_cols: typing.List[SchemaType.SchemaColumn] = [] + for k, v in t.columns().items(): + if v not in self._SUPPORTED_TYPES: + raise AssertionError(f"type {v} is currently not supported by FlyteSchema") + converted_cols.append(SchemaType.SchemaColumn(name=k, type=self._SUPPORTED_TYPES[v])) + return SchemaType(columns=converted_cols) + + def assert_type(self, t: Type[FlyteSchema], v: typing.Any): + if issubclass(t, FlyteSchema) or isinstance(v, FlyteSchema): + return + try: + SchemaEngine.get_handler(type(v)) + except ValueError as e: + raise TypeError(f"No automatic conversion found from type {type(v)} to FlyteSchema") from e + + def get_literal_type(self, t: Type[FlyteSchema]) -> LiteralType: + return LiteralType(schema=self._get_schema_type(t)) + + def to_literal( + self, ctx: FlyteContext, python_val: FlyteSchema, python_type: Type[FlyteSchema], expected: LiteralType + ) -> Literal: + if isinstance(python_val, FlyteSchema): + remote_path = python_val.remote_path + if remote_path is None or remote_path == "": + remote_path = ctx.file_access.join( + ctx.file_access.raw_output_prefix, + ctx.file_access.get_random_string(), + ctx.file_access.get_file_tail(python_val.local_path), + ) + if python_val.supported_mode == SchemaOpenMode.READ and not python_val._downloaded: + # This means the local path is empty. Don't try to overwrite the remote data + logger.debug(f"Skipping upload for {python_val} because it was never downloaded.") + else: + remote_path = ctx.file_access.put_data(python_val.local_path, remote_path, is_multipart=True) + return Literal(scalar=Scalar(schema=Schema(remote_path, self._get_schema_type(python_type)))) + + remote_path = ctx.file_access.join(ctx.file_access.raw_output_prefix, ctx.file_access.get_random_string()) + schema = python_type( + local_path=ctx.file_access.get_random_local_directory(), + remote_path=remote_path, + ) + try: + h = SchemaEngine.get_handler(type(python_val)) + except ValueError as e: + raise TypeTransformerFailedError( + f"DataFrames of type {type(python_val)} are not supported currently" + ) from e + writer = schema.open(type(python_val)) + writer.write(python_val) + if not h.handles_remote_io: + schema.remote_path = ctx.file_access.put_data(schema.local_path, schema.remote_path, is_multipart=True) + return Literal(scalar=Scalar(schema=Schema(schema.remote_path, self._get_schema_type(python_type)))) + + def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[FlyteSchema]) -> FlyteSchema: + def downloader(x, y): + ctx.file_access.get_data(x, y, is_multipart=True) + + if lv and lv.scalar and lv.scalar.structured_dataset: + return expected_python_type( + local_path=ctx.file_access.get_random_local_directory(), + remote_path=lv.scalar.structured_dataset.uri, + downloader=downloader, + supported_mode=SchemaOpenMode.READ, + ) + if not (lv and lv.scalar and lv.scalar.schema): + raise AssertionError("Can only convert a literal schema to a FlyteSchema") + + return expected_python_type( + local_path=ctx.file_access.get_random_local_directory(), + remote_path=lv.scalar.schema.uri, + downloader=downloader, + supported_mode=SchemaOpenMode.READ, + ) + + def guess_python_type(self, literal_type: LiteralType) -> Type[FlyteSchema]: + if not literal_type.schema: + raise ValueError(f"Cannot reverse {literal_type}") + columns: typing.Dict[str, Type] = {} + for literal_column in literal_type.schema.columns: + if literal_column.type == SchemaType.SchemaColumn.SchemaColumnType.INTEGER: + columns[literal_column.name] = int + elif literal_column.type == SchemaType.SchemaColumn.SchemaColumnType.FLOAT: + columns[literal_column.name] = float + elif literal_column.type == SchemaType.SchemaColumn.SchemaColumnType.STRING: + columns[literal_column.name] = str + elif literal_column.type == SchemaType.SchemaColumn.SchemaColumnType.DATETIME: + columns[literal_column.name] = _datetime.datetime + elif literal_column.type == SchemaType.SchemaColumn.SchemaColumnType.DURATION: + columns[literal_column.name] = _datetime.timedelta + elif literal_column.type == SchemaType.SchemaColumn.SchemaColumnType.BOOLEAN: + columns[literal_column.name] = bool + else: + raise ValueError(f"Unknown schema column type {literal_column}") + return FlyteSchema.__class_getitem__(columns) + + +TypeEngine.register(FlyteSchemaTransformer()) diff --git a/flytekit/flytekit/types/schema/types_pandas.py b/flytekit/flytekit/types/schema/types_pandas.py new file mode 100644 index 0000000000..a7ade2fe46 --- /dev/null +++ b/flytekit/flytekit/types/schema/types_pandas.py @@ -0,0 +1,128 @@ +import os +import typing +from typing import Type + +import pandas + +from flytekit import FlyteContext +from flytekit.core.type_engine import T, TypeEngine, TypeTransformer +from flytekit.models.literals import Literal, Scalar, Schema +from flytekit.models.types import LiteralType, SchemaType +from flytekit.types.schema import LocalIOSchemaReader, LocalIOSchemaWriter, SchemaEngine, SchemaFormat, SchemaHandler + + +class ParquetIO(object): + PARQUET_ENGINE = "pyarrow" + + def _read(self, chunk: os.PathLike, columns: typing.Optional[typing.List[str]], **kwargs) -> pandas.DataFrame: + return pandas.read_parquet(chunk, columns=columns, engine=self.PARQUET_ENGINE, **kwargs) + + def read( + self, *files: os.PathLike, columns: typing.Optional[typing.List[str]] = None, **kwargs + ) -> pandas.DataFrame: + frames = [self._read(chunk=f, columns=columns, **kwargs) for f in files if os.path.getsize(f) > 0] + if len(frames) == 1: + return frames[0] + elif len(frames) > 1: + return pandas.concat(frames, copy=True) + return pandas.DataFrame() + + def write( + self, + df: pandas.DataFrame, + to_file: os.PathLike, + coerce_timestamps: str = "us", + allow_truncated_timestamps: bool = False, + **kwargs, + ): + """ + Writes data frame as a chunk to the local directory owned by the Schema object. Will later be uploaded to s3. + :param df: data frame to write as parquet + :param to_file: Sink file to write the dataframe to + :param coerce_timestamps: format to store timestamp in parquet. 'us', 'ms', 's' are allowed values. + Note: if your timestamps will lose data due to the coercion, your write will fail! Nanoseconds are + problematic in the Parquet format and will not work. See allow_truncated_timestamps. + :param allow_truncated_timestamps: default False. Allow truncation when coercing timestamps to a coarser + resolution. + """ + # TODO @ketan validate and remove this comment, as python 3 all strings are unicode + # Convert all columns to unicode as pyarrow's parquet reader can not handle mixed strings and unicode. + # Since columns from Hive are returned as unicode, if a user wants to add a column to a dataframe returned from + # Hive, then output the new data, the user would have to provide a unicode column name which is unnatural. + df.to_parquet( + to_file, + coerce_timestamps=coerce_timestamps, + allow_truncated_timestamps=allow_truncated_timestamps, + **kwargs, + ) + + +class PandasSchemaReader(LocalIOSchemaReader[pandas.DataFrame]): + def __init__(self, local_dir: str, cols: typing.Optional[typing.Dict[str, type]], fmt: SchemaFormat): + super().__init__(local_dir, cols, fmt) + self._parquet_engine = ParquetIO() + + def _read(self, *path: os.PathLike, **kwargs) -> pandas.DataFrame: + return self._parquet_engine.read(*path, columns=self.column_names, **kwargs) + + +class PandasSchemaWriter(LocalIOSchemaWriter[pandas.DataFrame]): + def __init__(self, local_dir: str, cols: typing.Optional[typing.Dict[str, type]], fmt: SchemaFormat): + super().__init__(local_dir, cols, fmt) + self._parquet_engine = ParquetIO() + + def _write(self, df: T, path: os.PathLike, **kwargs): + return self._parquet_engine.write(df, to_file=path, **kwargs) + + +class PandasDataFrameTransformer(TypeTransformer[pandas.DataFrame]): + """ + Transforms a pd.DataFrame to Schema without column types. + """ + + def __init__(self): + super().__init__("PandasDataFrame<->GenericSchema", pandas.DataFrame) + self._parquet_engine = ParquetIO() + + @staticmethod + def _get_schema_type() -> SchemaType: + return SchemaType(columns=[]) + + def get_literal_type(self, t: Type[pandas.DataFrame]) -> LiteralType: + return LiteralType(schema=self._get_schema_type()) + + def to_literal( + self, + ctx: FlyteContext, + python_val: pandas.DataFrame, + python_type: Type[pandas.DataFrame], + expected: LiteralType, + ) -> Literal: + local_dir = ctx.file_access.get_random_local_directory() + w = PandasSchemaWriter(local_dir=local_dir, cols=None, fmt=SchemaFormat.PARQUET) + w.write(python_val) + remote_path = ctx.file_access.join( + ctx.file_access.raw_output_prefix, + ctx.file_access.get_random_string(), + ) + remote_path = ctx.file_access.put_data(local_dir, remote_path, is_multipart=True) + return Literal(scalar=Scalar(schema=Schema(remote_path, self._get_schema_type()))) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[pandas.DataFrame] + ) -> pandas.DataFrame: + if not (lv and lv.scalar and lv.scalar.schema): + return pandas.DataFrame() + local_dir = ctx.file_access.get_random_local_directory() + ctx.file_access.get_data(lv.scalar.schema.uri, local_dir, is_multipart=True) + r = PandasSchemaReader(local_dir=local_dir, cols=None, fmt=SchemaFormat.PARQUET) + return r.all() + + def to_html(self, ctx: FlyteContext, python_val: pandas.DataFrame, expected_python_type: Type[T]): + return python_val.describe().to_html() + + +SchemaEngine.register_handler( + SchemaHandler("pandas-dataframe-schema", pandas.DataFrame, PandasSchemaReader, PandasSchemaWriter) +) +TypeEngine.register(PandasDataFrameTransformer()) diff --git a/flytekit/flytekit/types/structured/__init__.py b/flytekit/flytekit/types/structured/__init__.py new file mode 100644 index 0000000000..7c92be78b1 --- /dev/null +++ b/flytekit/flytekit/types/structured/__init__.py @@ -0,0 +1,71 @@ +""" +Flytekit StructuredDataset +========================================================== +.. currentmodule:: flytekit.types.structured + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + StructuredDataset + StructuredDatasetEncoder + StructuredDatasetDecoder +""" + + +from flytekit.deck.renderer import ArrowRenderer, TopFrameRenderer +from flytekit.loggers import logger + +from .structured_dataset import ( + StructuredDataset, + StructuredDatasetDecoder, + StructuredDatasetEncoder, + StructuredDatasetTransformerEngine, +) + + +def register_csv_handlers(): + from .basic_dfs import CSVToPandasDecodingHandler, PandasToCSVEncodingHandler + + StructuredDatasetTransformerEngine.register(PandasToCSVEncodingHandler(), default_format_for_type=True) + StructuredDatasetTransformerEngine.register(CSVToPandasDecodingHandler(), default_format_for_type=True) + + +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/flytekit/types/structured/basic_dfs.py b/flytekit/flytekit/types/structured/basic_dfs.py new file mode 100644 index 0000000000..1c21a6b2d8 --- /dev/null +++ b/flytekit/flytekit/types/structured/basic_dfs.py @@ -0,0 +1,192 @@ +import os +import typing +from pathlib import Path +from typing import TypeVar + +from botocore.exceptions import NoCredentialsError +from fsspec.core import split_protocol, strip_protocol +from fsspec.utils import get_protocol + +from flytekit import FlyteContext, lazy_module, logger +from flytekit.configuration import DataConfig +from flytekit.core.data_persistence import get_fsspec_storage_options +from flytekit.models import literals +from flytekit.models.literals import StructuredDatasetMetadata +from flytekit.models.types import StructuredDatasetType +from flytekit.types.structured.structured_dataset import ( + CSV, + PARQUET, + StructuredDataset, + StructuredDatasetDecoder, + StructuredDatasetEncoder, +) + +if typing.TYPE_CHECKING: + import pandas as pd + import pyarrow as pa +else: + pd = lazy_module("pandas") + pa = lazy_module("pyarrow") + +T = TypeVar("T") + + +def get_pandas_storage_options( + uri: str, data_config: DataConfig, anonymous: bool = False +) -> typing.Optional[typing.Dict]: + if pd.io.common.is_fsspec_url(uri): + return get_fsspec_storage_options(protocol=get_protocol(uri), data_config=data_config, anonymous=anonymous) + + # Pandas does not allow storage_options for non-fsspec paths e.g. local. + return None + + +class PandasToCSVEncodingHandler(StructuredDatasetEncoder): + def __init__(self): + super().__init__(pd.DataFrame, None, CSV) + + 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, ".csv") + df = typing.cast(pd.DataFrame, structured_dataset.dataframe) + df.to_csv( + path, + index=False, + storage_options=get_pandas_storage_options(uri=path, data_config=ctx.file_access.data_config), + ) + structured_dataset_type.format = CSV + return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) + + +class CSVToPandasDecodingHandler(StructuredDatasetDecoder): + def __init__(self): + super().__init__(pd.DataFrame, None, CSV) + + def decode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, + ) -> "pd.DataFrame": + uri = flyte_value.uri + columns = None + kwargs = get_pandas_storage_options(uri=uri, data_config=ctx.file_access.data_config) + path = os.path.join(uri, ".csv") + 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_csv(path, usecols=columns, storage_options=kwargs) + except NoCredentialsError: + logger.debug("S3 source detected, attempting anonymous S3 access") + kwargs = get_pandas_storage_options(uri=uri, data_config=ctx.file_access.data_config, anonymous=True) + return pd.read_csv(path, usecols=columns, storage_options=kwargs) + + +class PandasToParquetEncodingHandler(StructuredDatasetEncoder): + def __init__(self): + super().__init__(pd.DataFrame, None, 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.join( + ctx.file_access.raw_output_prefix, ctx.file_access.get_random_string() + ) + 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_pandas_storage_options(uri=path, data_config=ctx.file_access.data_config), + ) + structured_dataset_type.format = PARQUET + return literals.StructuredDataset(uri=uri, metadata=StructuredDatasetMetadata(structured_dataset_type)) + + +class ParquetToPandasDecodingHandler(StructuredDatasetDecoder): + def __init__(self): + super().__init__(pd.DataFrame, None, PARQUET) + + def decode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, + ) -> "pd.DataFrame": + uri = flyte_value.uri + columns = None + kwargs = get_pandas_storage_options(uri=uri, data_config=ctx.file_access.data_config) + if current_task_metadata.structured_dataset_type and current_task_metadata.structured_dataset_type.columns: + columns = [c.name for c in current_task_metadata.structured_dataset_type.columns] + try: + return pd.read_parquet(uri, columns=columns, storage_options=kwargs) + except NoCredentialsError: + logger.debug("S3 source detected, attempting anonymous S3 access") + kwargs = get_pandas_storage_options(uri=uri, data_config=ctx.file_access.data_config, anonymous=True) + return pd.read_parquet(uri, columns=columns, storage_options=kwargs) + + +class ArrowToParquetEncodingHandler(StructuredDatasetEncoder): + def __init__(self): + super().__init__(pa.Table, None, PARQUET) + + def encode( + self, + ctx: FlyteContext, + structured_dataset: StructuredDataset, + structured_dataset_type: StructuredDatasetType, + ) -> literals.StructuredDataset: + import pyarrow.parquet as pq + + uri = typing.cast(str, structured_dataset.uri) or ctx.file_access.join( + ctx.file_access.raw_output_prefix, ctx.file_access.get_random_string() + ) + 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): + def __init__(self): + super().__init__(pa.Table, None, PARQUET) + + def decode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, + ) -> "pa.Table": + import pyarrow.parquet as pq + + 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: + return pq.read_table(path, 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/flytekit/types/structured/bigquery.py b/flytekit/flytekit/types/structured/bigquery.py new file mode 100644 index 0000000000..e9c3ae390d --- /dev/null +++ b/flytekit/flytekit/types/structured/bigquery.py @@ -0,0 +1,116 @@ +import re +import typing + +from google.cloud import bigquery, bigquery_storage +from google.cloud.bigquery_storage_v1 import types + +from flytekit import FlyteContext, lazy_module +from flytekit.models import literals +from flytekit.models.types import StructuredDatasetType +from flytekit.types.structured.structured_dataset import ( + StructuredDataset, + StructuredDatasetDecoder, + StructuredDatasetEncoder, + StructuredDatasetMetadata, +) + +if typing.TYPE_CHECKING: + import pandas as pd + import pyarrow as pa +else: + pd = lazy_module("pandas") + pa = lazy_module("pyarrow") + +BIGQUERY = "bq" + + +def _write_to_bq(structured_dataset: StructuredDataset): + table_id = typing.cast(str, structured_dataset.uri).split("://", 1)[1].replace(":", ".") + client = bigquery.Client() + df = structured_dataset.dataframe + if isinstance(df, pa.Table): + df = df.to_pandas() + client.load_table_from_dataframe(df, table_id) + + +def _read_from_bq( + flyte_value: literals.StructuredDataset, current_task_metadata: StructuredDatasetMetadata +) -> pd.DataFrame: + path = flyte_value.uri + _, project_id, dataset_id, table_id = re.split("\\.|://|:", path) + client = bigquery_storage.BigQueryReadClient() + table = f"projects/{project_id}/datasets/{dataset_id}/tables/{table_id}" + parent = "projects/{}".format(project_id) + + read_options = 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] + read_options = types.ReadSession.TableReadOptions(selected_fields=columns) + + requested_session = types.ReadSession(table=table, data_format=types.DataFormat.ARROW, read_options=read_options) + read_session = client.create_read_session(parent=parent, read_session=requested_session) + + stream = read_session.streams[0] + reader = client.read_rows(stream.name) + frames = [] + for message in reader.rows().pages: + frames.append(message.to_dataframe()) + return pd.concat(frames) + + +class PandasToBQEncodingHandlers(StructuredDatasetEncoder): + def __init__(self): + super().__init__(pd.DataFrame, BIGQUERY, supported_format="") + + def encode( + self, + ctx: FlyteContext, + structured_dataset: StructuredDataset, + structured_dataset_type: StructuredDatasetType, + ) -> literals.StructuredDataset: + _write_to_bq(structured_dataset) + return literals.StructuredDataset( + uri=typing.cast(str, structured_dataset.uri), metadata=StructuredDatasetMetadata(structured_dataset_type) + ) + + +class BQToPandasDecodingHandler(StructuredDatasetDecoder): + def __init__(self): + super().__init__(pd.DataFrame, BIGQUERY, supported_format="") + + def decode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, + ) -> pd.DataFrame: + return _read_from_bq(flyte_value, current_task_metadata) + + +class ArrowToBQEncodingHandlers(StructuredDatasetEncoder): + def __init__(self): + super().__init__(pa.Table, BIGQUERY, supported_format="") + + def encode( + self, + ctx: FlyteContext, + structured_dataset: StructuredDataset, + structured_dataset_type: StructuredDatasetType, + ) -> literals.StructuredDataset: + _write_to_bq(structured_dataset) + return literals.StructuredDataset( + uri=typing.cast(str, structured_dataset.uri), metadata=StructuredDatasetMetadata(structured_dataset_type) + ) + + +class BQToArrowDecodingHandler(StructuredDatasetDecoder): + def __init__(self): + super().__init__(pa.Table, BIGQUERY, supported_format="") + + def decode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, + ) -> pa.Table: + return pa.Table.from_pandas(_read_from_bq(flyte_value, current_task_metadata)) diff --git a/flytekit/flytekit/types/structured/structured_dataset.py b/flytekit/flytekit/types/structured/structured_dataset.py new file mode 100644 index 0000000000..1d7af31404 --- /dev/null +++ b/flytekit/flytekit/types/structured/structured_dataset.py @@ -0,0 +1,868 @@ +from __future__ import annotations + +import collections +import types +import typing +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Dict, Generator, Optional, Type, Union + +import _datetime +from dataclasses_json import config +from fsspec.utils import get_protocol +from marshmallow import fields +from mashumaro.mixins.json import DataClassJSONMixin +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.type_engine import TypeEngine, TypeTransformer +from flytekit.deck.renderer import Renderable +from flytekit.loggers import logger +from flytekit.models import literals +from flytekit.models import types as type_models +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 + +# For specifying the storage formats of StructuredDatasets. It's just a string, nothing fancy. +StructuredDatasetFormat: TypeAlias = str + +# Storage formats +PARQUET: StructuredDatasetFormat = "parquet" +CSV: StructuredDatasetFormat = "csv" +GENERIC_FORMAT: StructuredDatasetFormat = "" +GENERIC_PROTOCOL: str = "generic protocol" + + +@dataclass +class StructuredDataset(DataClassJSONMixin): + """ + This is the user facing StructuredDataset class. Please don't confuse it with the literals.StructuredDataset + class (that is just a model, a Python class representation of the protobuf). + """ + + uri: typing.Optional[str] = field(default=None, metadata=config(mm_field=fields.String())) + file_format: typing.Optional[str] = field(default=GENERIC_FORMAT, metadata=config(mm_field=fields.String())) + + @classmethod + def columns(cls) -> typing.Dict[str, typing.Type]: + return {} + + @classmethod + def column_names(cls) -> typing.List[str]: + return [k for k, v in cls.columns().items()] + + def __init__( + self, + dataframe: typing.Optional[typing.Any] = None, + uri: typing.Optional[str] = None, + metadata: typing.Optional[literals.StructuredDatasetMetadata] = None, + **kwargs, + ): + self._dataframe = dataframe + # Make these fields public, so that the dataclass transformer can set a value for it + # https://github.com/flyteorg/flytekit/blob/bcc8541bd6227b532f8462563fe8aac902242b21/flytekit/core/type_engine.py#L298 + self.uri = uri + # When dataclass_json runs from_json, we need to set it here, otherwise the format will be empty string + self.file_format = kwargs["file_format"] if "file_format" in kwargs else GENERIC_FORMAT + # This is a special attribute that indicates if the data was either downloaded or uploaded + self._metadata = metadata + # 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[DF] = None # type: ignore + self._already_uploaded = False + + @property + def dataframe(self) -> Optional[DF]: + return self._dataframe + + @property + def metadata(self) -> Optional[StructuredDatasetMetadata]: + return self._metadata + + @property + def literal(self) -> Optional[literals.StructuredDataset]: + return self._literal_sd + + def open(self, dataframe_type: Type[DF]): + self._dataframe_type = dataframe_type + return self + + def all(self) -> DF: # type: ignore + if self._dataframe_type is None: + raise ValueError("No dataframe type set. Use open() to set the local dataframe type you want to use.") + ctx = FlyteContextManager.current_context() + return flyte_dataset_transformer.open_as(ctx, self.literal, self._dataframe_type, self.metadata) + + def iter(self) -> Generator[DF, None, None]: + if self._dataframe_type is None: + raise ValueError("No dataframe type set. Use open() to set the local dataframe type you want to use.") + ctx = FlyteContextManager.current_context() + return flyte_dataset_transformer.iter_as( + ctx, self.literal, self._dataframe_type, updated_metadata=self.metadata + ) + + +def extract_cols_and_format( + t: typing.Any, +) -> 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. + - column information, as a collections.OrderedDict, + - the storage format, as a ``StructuredDatasetFormat`` (str), + - pa.lib.Schema + + If more than one of any type of thing is found, an error will be raised. + If no instances of a given type are found, then None will be returned. + + If we add more things, we should put all the returned items in a dataclass instead of just a tuple. + + :param t: The incoming type which may or may not be Annotated + :return: Tuple representing + the original type, + optional OrderedDict of columns, + optional str for the format, + optional pyarrow Schema + """ + fmt = "" + ordered_dict_cols = None + pa_schema = None + if get_origin(t) is Annotated: + base_type, *annotate_args = get_args(t) + for aa in annotate_args: + if isinstance(aa, StructuredDatasetFormat): + if fmt != "": + raise ValueError(f"A format was already specified {fmt}, cannot use {aa}") + fmt = aa + elif isinstance(aa, collections.OrderedDict): + 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.lib.Schema): + if pa_schema is not None: + raise ValueError(f"Arrow schema was already found {pa_schema}, cannot use {aa}") + pa_schema = aa + return base_type, ordered_dict_cols, fmt, pa_schema + + # We return None as the format instead of parquet or something because the transformer engine may find + # a better default for the given dataframe type. + return t, ordered_dict_cols, fmt, pa_schema + + +class StructuredDatasetEncoder(ABC): + def __init__(self, python_type: Type[T], protocol: Optional[str] = None, supported_format: Optional[str] = None): + """ + Extend this abstract class, implement the encode function, and register your concrete class with the + StructuredDatasetTransformerEngine class in order for the core flytekit type engine to handle + dataframe libraries. This is the encoding interface, meaning it is used when there is a Python value that the + flytekit type engine is trying to convert into a Flyte Literal. For the other way, see + the StructuredDatasetEncoder + + :param python_type: The dataframe class in question that you want to register this encoder with + :param protocol: A prefix representing the storage driver (e.g. 's3, 'gs', 'bq', etc.). You can use either + "s3" or "s3://". They are the same since the "://" will just be stripped by the constructor. + If None, this encoder will be registered with all protocols that flytekit's data persistence layer + is capable of handling. + :param supported_format: Arbitrary string representing the format. If not supplied then an empty string + will be used. An empty string implies that the encoder works with any format. If the format being asked + for does not exist, the transformer enginer will look for the "" encoder instead and write a warning. + """ + self._python_type = python_type + self._protocol = protocol.replace("://", "") if protocol else None + self._supported_format = supported_format or "" + + @property + def python_type(self) -> Type[T]: + return self._python_type + + @property + def protocol(self) -> Optional[str]: + return self._protocol + + @property + def supported_format(self) -> str: + return self._supported_format + + @abstractmethod + def encode( + self, + ctx: FlyteContext, + structured_dataset: StructuredDataset, + structured_dataset_type: StructuredDatasetType, + ) -> literals.StructuredDataset: + """ + Even if the user code returns a plain dataframe instance, the dataset transformer engine will wrap the + incoming dataframe with defaults set for that dataframe + type. This simplifies this function's interface as a lot of data that could be specified by the user using + the + # TODO: Do we need to add a flag to indicate if it was wrapped by the transformer or by the user? + + :param ctx: + :param structured_dataset: This is a StructuredDataset wrapper object. See more info above. + :param structured_dataset_type: This the StructuredDatasetType, as found in the LiteralType of the interface + of the task that invoked this encoding call. It is passed along to encoders so that authors of encoders + can include it in the returned literals.StructuredDataset. See the IDL for more information on why this + literal in particular carries the type information along with it. If the encoder doesn't supply it, it will + also be filled in after the encoder runs by the transformer engine. + :return: This function should return a StructuredDataset literal object. Do not confuse this with the + StructuredDataset wrapper class used as input to this function - that is the user facing Python class. + This function needs to return the IDL StructuredDataset. + """ + raise NotImplementedError + + +class StructuredDatasetDecoder(ABC): + def __init__(self, python_type: Type[DF], protocol: Optional[str] = None, supported_format: Optional[str] = None): + """ + Extend this abstract class, implement the decode function, and register your concrete class with the + StructuredDatasetTransformerEngine class in order for the core flytekit type engine to handle + dataframe libraries. This is the decoder interface, meaning it is used when there is a Flyte Literal value, + and we have to get a Python value out of it. For the other way, see the StructuredDatasetEncoder + + :param python_type: The dataframe class in question that you want to register this decoder with + :param protocol: A prefix representing the storage driver (e.g. 's3, 'gs', 'bq', etc.). You can use either + "s3" or "s3://". They are the same since the "://" will just be stripped by the constructor. + If None, this decoder will be registered with all protocols that flytekit's data persistence layer + is capable of handling. + :param supported_format: Arbitrary string representing the format. If not supplied then an empty string + will be used. An empty string implies that the decoder works with any format. If the format being asked + for does not exist, the transformer enginer will look for the "" decoder instead and write a warning. + """ + self._python_type = python_type + self._protocol = protocol.replace("://", "") if protocol else None + self._supported_format = supported_format or "" + + @property + def python_type(self) -> Type[DF]: + return self._python_type + + @property + def protocol(self) -> Optional[str]: + return self._protocol + + @property + def supported_format(self) -> str: + return self._supported_format + + @abstractmethod + def decode( + self, + ctx: FlyteContext, + flyte_value: literals.StructuredDataset, + current_task_metadata: StructuredDatasetMetadata, + ) -> Union[DF, typing.Iterator[DF]]: + """ + This is code that will be called by the dataset transformer engine to ultimately translate from a Flyte Literal + value into a Python instance. + + :param ctx: A FlyteContext, useful in accessing the filesystem and other attributes + :param flyte_value: This will be a Flyte IDL StructuredDataset Literal - do not confuse this with the + StructuredDataset class defined also in this module. + :param current_task_metadata: Metadata object containing the type (and columns if any) for the currently + executing task. This type may have more or less information than the type information bundled inside the incoming flyte_value. + :return: This function can either return an instance of the dataframe that this decoder handles, or an iterator + of those dataframes. + """ + raise NotImplementedError + + +def convert_schema_type_to_structured_dataset_type( + column_type: int, +) -> int: + if column_type == SchemaType.SchemaColumn.SchemaColumnType.INTEGER: + return type_models.SimpleType.INTEGER + if column_type == SchemaType.SchemaColumn.SchemaColumnType.FLOAT: + return type_models.SimpleType.FLOAT + if column_type == SchemaType.SchemaColumn.SchemaColumnType.STRING: + return type_models.SimpleType.STRING + if column_type == SchemaType.SchemaColumn.SchemaColumnType.DATETIME: + return type_models.SimpleType.DATETIME + if column_type == SchemaType.SchemaColumn.SchemaColumnType.DURATION: + return type_models.SimpleType.DURATION + if column_type == SchemaType.SchemaColumn.SchemaColumnType.BOOLEAN: + return type_models.SimpleType.BOOLEAN + else: + raise AssertionError(f"Unrecognized SchemaColumnType: {column_type}") + + +def get_supported_types(): + import numpy as _np + + _SUPPORTED_TYPES: typing.Dict[Type, LiteralType] = { # type: ignore + _np.int32: type_models.LiteralType(simple=type_models.SimpleType.INTEGER), + _np.int64: type_models.LiteralType(simple=type_models.SimpleType.INTEGER), + _np.uint32: type_models.LiteralType(simple=type_models.SimpleType.INTEGER), + _np.uint64: type_models.LiteralType(simple=type_models.SimpleType.INTEGER), + int: type_models.LiteralType(simple=type_models.SimpleType.INTEGER), + _np.float32: type_models.LiteralType(simple=type_models.SimpleType.FLOAT), + _np.float64: type_models.LiteralType(simple=type_models.SimpleType.FLOAT), + float: type_models.LiteralType(simple=type_models.SimpleType.FLOAT), + _np.bool_: type_models.LiteralType(simple=type_models.SimpleType.BOOLEAN), # type: ignore + bool: type_models.LiteralType(simple=type_models.SimpleType.BOOLEAN), + _np.datetime64: type_models.LiteralType(simple=type_models.SimpleType.DATETIME), + _datetime.datetime: type_models.LiteralType(simple=type_models.SimpleType.DATETIME), + _np.timedelta64: type_models.LiteralType(simple=type_models.SimpleType.DURATION), + _datetime.timedelta: type_models.LiteralType(simple=type_models.SimpleType.DURATION), + _np.string_: type_models.LiteralType(simple=type_models.SimpleType.STRING), + _np.str_: type_models.LiteralType(simple=type_models.SimpleType.STRING), + _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]]] = {} + DEFAULT_PROTOCOLS: Dict[Type, str] = {} + DEFAULT_FORMATS: Dict[Type, str] = {} + + Handlers = Union[StructuredDatasetEncoder, StructuredDatasetDecoder] + Renderers: Dict[Type, Renderable] = {} + + @classmethod + def _finder(cls, handler_map, df_type: Type, protocol: str, format: str): + # 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: + 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): + return cls._finder(StructuredDatasetTransformerEngine.ENCODERS, df_type, protocol, format) + + @classmethod + def get_decoder(cls, df_type: Type, protocol: str, format: str): + return cls._finder(StructuredDatasetTransformerEngine.DECODERS, df_type, protocol, format) + + @classmethod + def _handler_finder(cls, h: Handlers, protocol: str) -> Dict[str, Handlers]: + if isinstance(h, StructuredDatasetEncoder): + top_level = cls.ENCODERS + elif isinstance(h, StructuredDatasetDecoder): + top_level = cls.DECODERS # type: ignore + else: + raise TypeError(f"We don't support this type of handler {h}") + if h.python_type not in top_level: + top_level[h.python_type] = {} + if protocol not in top_level[h.python_type]: + top_level[h.python_type][protocol] = {} + return top_level[h.python_type][protocol] # type: ignore + + def __init__(self): + super().__init__("StructuredDataset Transformer", StructuredDataset) + self._type_assertions_enabled = False + + @classmethod + def register_renderer(cls, python_type: Type, renderer: Renderable): + cls.Renderers[python_type] = renderer + + @classmethod + def register( + cls, + h: Handlers, + default_for_type: bool = False, + override: bool = False, + default_format_for_type: bool = False, + default_storage_for_type: bool = False, + ): + """ + Call this with any Encoder or Decoder to register it with the flytekit type system. If your handler does not + specify a protocol (e.g. s3, gs, etc.) field, then + + :param h: The StructuredDatasetEncoder or StructuredDatasetDecoder you wish to register with this transformer. + :param default_for_type: If set, when a user returns from a task an instance of the dataframe the handler + handles, e.g. ``return pd.DataFrame(...)``, not wrapped around the ``StructuredDataset`` object, we will + use this handler's protocol and format as the default, effectively saying that this handler will be called. + Note that this shouldn't be set if your handler's protocol is None, because that implies that your handler + is capable of handling all the different storage protocols that flytekit's data persistence layer is aware of. + In these cases, the protocol is determined by the raw output data prefix set in the active context. + :param override: Override any previous registrations. If default_for_type is also set, this will also override + the default. + :param default_format_for_type: Unlike the default_for_type arg that will set this handler's format and storage + as the default, this will only set the format. Error if already set, unless override is specified. + :param default_storage_for_type: Same as above but only for the storage format. Error if already set, + unless override is specified. + """ + if not (isinstance(h, StructuredDatasetEncoder) or isinstance(h, StructuredDatasetDecoder)): + raise TypeError(f"We don't support this type of handler {h}") + + if h.protocol is None: + if default_for_type: + raise ValueError(f"Registering SD handler {h} with all protocols should never have default specified.") + 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}") + else: + cls.register_for_protocol( + h, h.protocol, default_for_type, override, default_format_for_type, default_storage_for_type + ) + + @classmethod + def register_for_protocol( + cls, + h: Handlers, + protocol: str, + default_for_type: bool, + override: bool, + default_format_for_type: bool, + default_storage_for_type: bool, + ): + """ + See the main register function instead. + """ + if protocol == "/": + protocol = "file" + lowest_level = cls._handler_finder(h, protocol) + if h.supported_format in lowest_level and override is False: + raise DuplicateHandlerError( + f"Already registered a handler for {(h.python_type, protocol, h.supported_format)}" + ) + lowest_level[h.supported_format] = h + logger.debug(f"Registered {h} as handler for {h.python_type}, protocol {protocol}, fmt {h.supported_format}") + + if (default_format_for_type or default_for_type) and h.supported_format != GENERIC_FORMAT: + if h.python_type in cls.DEFAULT_FORMATS and not override: + if cls.DEFAULT_FORMATS[h.python_type] != h.supported_format: + logger.info( + f"Not using handler {h} with format {h.supported_format} as default for {h.python_type}, {cls.DEFAULT_FORMATS[h.python_type]} already specified." + ) + else: + logger.debug( + f"Setting format {h.supported_format} for dataframes of type {h.python_type} from handler {h}" + ) + cls.DEFAULT_FORMATS[h.python_type] = h.supported_format + if default_storage_for_type or default_for_type: + if h.protocol in cls.DEFAULT_PROTOCOLS and not override: + logger.debug( + f"Not using handler {h} with storage protocol {h.protocol} as default for {h.python_type}, {cls.DEFAULT_PROTOCOLS[h.python_type]} already specified." + ) + else: + logger.debug(f"Using storage {protocol} for dataframes of type {h.python_type} from handler {h}") + cls.DEFAULT_PROTOCOLS[h.python_type] = protocol + + # Register with the type engine as well + # The semantics as of now are such that it doesn't matter which order these transformers are loaded in, as + # long as the older Pandas/FlyteSchema transformer do not also specify the override + engine = StructuredDatasetTransformerEngine() + TypeEngine.register_additional_type(engine, h.python_type, override=True) + + def assert_type(self, t: Type[StructuredDataset], v: typing.Any): + return + + def to_literal( + self, + ctx: FlyteContext, + python_val: Union[StructuredDataset, typing.Any], + python_type: Union[Type[StructuredDataset], Type], + expected: LiteralType, + ) -> Literal: + # Make a copy in case we need to hand off to encoders, since we can't be sure of mutations. + # Check first to see if it's even an SD type. For backwards compatibility, we may be getting a FlyteSchema + python_type, *attrs = extract_cols_and_format(python_type) + # In case it's a FlyteSchema + sdt = StructuredDatasetType(format=self.DEFAULT_FORMATS.get(python_type, GENERIC_FORMAT)) + + if expected and expected.structured_dataset_type: + sdt = StructuredDatasetType( + columns=expected.structured_dataset_type.columns, + format=expected.structured_dataset_type.format, + external_schema_type=expected.structured_dataset_type.external_schema_type, + external_schema_bytes=expected.structured_dataset_type.external_schema_bytes, + ) + + # If the type signature has the StructuredDataset class, it will, or at least should, also be a + # StructuredDataset instance. + if isinstance(python_val, StructuredDataset): + # There are three cases that we need to take care of here. + + # 1. A task returns a StructuredDataset that was just a passthrough input. If this happens + # then return the original literals.StructuredDataset without invoking any encoder + # + # Ex. + # 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 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. + # + # Ex. + # def t2(uri: str) -> Annotated[StructuredDataset, my_cols] + # return StructuredDataset(uri=uri) + if python_val.dataframe is None: + uri = python_val.uri + if not uri: + raise ValueError(f"If dataframe is not specified, then the uri should be specified. {python_val}") + if not ctx.file_access.is_remote(uri): + uri = ctx.file_access.put_raw_data(uri) + sd_model = literals.StructuredDataset( + uri=uri, + metadata=StructuredDatasetMetadata(structured_dataset_type=sdt), + ) + return Literal(scalar=Scalar(structured_dataset=sd_model)) + + # 3. This is the third and probably most common case. The python StructuredDataset object wraps a dataframe + # that we will need to invoke an encoder for. Figure out which encoder to call and invoke it. + df_type = type(python_val.dataframe) + protocol = self._protocol_from_type_or_prefix(ctx, df_type, python_val.uri) + return self.encode( + ctx, + python_val, + df_type, + protocol, + sdt.format, + sdt, + ) + + # Otherwise assume it's a dataframe instance. Wrap it with some defaults + fmt = self.DEFAULT_FORMATS.get(python_type, "") + protocol = self._protocol_from_type_or_prefix(ctx, python_type) + meta = StructuredDatasetMetadata(structured_dataset_type=expected.structured_dataset_type if expected else None) + + sd = StructuredDataset(dataframe=python_val, metadata=meta) + return self.encode(ctx, sd, python_type, protocol, fmt, sdt) + + def _protocol_from_type_or_prefix(self, ctx: FlyteContext, df_type: Type, uri: Optional[str] = None) -> str: + """ + Get the protocol from the default, if missing, then look it up from the uri if provided, if not then look + up from the provided context's file access. + """ + if df_type in self.DEFAULT_PROTOCOLS: + return self.DEFAULT_PROTOCOLS[df_type] + else: + 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}" + ) + return protocol + + def encode( + self, + ctx: FlyteContext, + sd: StructuredDataset, + df_type: Type, + protocol: str, + format: str, + structured_literal_type: StructuredDatasetType, + ) -> Literal: + handler: StructuredDatasetEncoder + handler = self.get_encoder(df_type, protocol, format) + sd_model = handler.encode(ctx, sd, structured_literal_type) + # This block is here in case the encoder did not set the type information in the metadata. Since this literal + # is special in that it carries around the type itself, we want to make sure the type info therein is at + # least as good as the type of the interface. + if sd_model.metadata is None: + sd_model._metadata = StructuredDatasetMetadata(structured_literal_type) + if sd_model.metadata and sd_model.metadata.structured_dataset_type is None: + sd_model.metadata._structured_dataset_type = structured_literal_type + # Always set the format here to the format of the handler. + # 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 + 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 + ) -> T | StructuredDataset: + """ + The only tricky thing with converting a Literal (say the output of an earlier task), to a Python value at + the start of a task execution, is the column subsetting behavior. For example, if you have, + + def t1() -> Annotated[StructuredDataset, kwtypes(col_a=int, col_b=float)]: ... + def t2(in_a: Annotated[StructuredDataset, kwtypes(col_b=float)]): ... + + where t2(in_a=t1()), when t2 does in_a.open(pd.DataFrame).all(), it should get a DataFrame + with only one column. + + +-----------------------------+-----------------------------------------+--------------------------------------+ + | | StructuredDatasetType of the incoming Literal | + +-----------------------------+-----------------------------------------+--------------------------------------+ + | StructuredDatasetType | Has columns defined | [] columns or None | + | of currently running task | | | + +=============================+=========================================+======================================+ + | Has columns | The StructuredDatasetType passed to the decoder will have the columns | + | defined | as defined by the type annotation of the currently running task. | + | | | + | | Decoders **should** then subset the incoming data to the columns requested. | + | | | + +-----------------------------+-----------------------------------------+--------------------------------------+ + | [] columns or None | StructuredDatasetType passed to decoder | StructuredDatasetType passed to the | + | | will have the columns from the incoming | decoder will have an empty list of | + | | Literal. This is the scenario where | columns. | + | | the Literal returned by the running | | + | | task will have more information than | | + | | the running task's signature. | | + +-----------------------------+-----------------------------------------+--------------------------------------+ + """ + # Detect annotations and extract out all the relevant information that the user might supply + expected_python_type, column_dict, storage_fmt, pa_schema = extract_cols_and_format(expected_python_type) + + # The literal that we get in might be an old FlyteSchema. + # We'll continue to support this for the time being. There is some duplicated logic here but let's + # keep it copy/pasted for clarity + if lv.scalar.schema is not None: + schema_columns = lv.scalar.schema.type.columns + + # See the repeated logic below for comments + if column_dict is None or len(column_dict) == 0: + final_dataset_columns = [] + if schema_columns is not None and schema_columns != []: + for c in schema_columns: + final_dataset_columns.append( + StructuredDatasetType.DatasetColumn( + name=c.name, + literal_type=LiteralType( + simple=convert_schema_type_to_structured_dataset_type(c.type), + ), + ) + ) + # Dataframe will always be serialized to parquet file by FlyteSchema transformer + new_sdt = StructuredDatasetType(columns=final_dataset_columns, format=PARQUET) + else: + final_dataset_columns = self._convert_ordered_dict_of_columns_to_list(column_dict) + # Dataframe will always be serialized to parquet file by FlyteSchema transformer + new_sdt = StructuredDatasetType(columns=final_dataset_columns, format=PARQUET) + + metad = literals.StructuredDatasetMetadata(structured_dataset_type=new_sdt) + sd_literal = literals.StructuredDataset( + uri=lv.scalar.schema.uri, + metadata=metad, + ) + + if issubclass(expected_python_type, StructuredDataset): + sd = StructuredDataset(dataframe=None, metadata=metad) + sd._literal_sd = sd_literal + return sd + else: + return self.open_as(ctx, sd_literal, expected_python_type, metad) + + # Start handling for StructuredDataset scalars, first look at the columns + incoming_columns = lv.scalar.structured_dataset.metadata.structured_dataset_type.columns + + # If the incoming literal, also doesn't have columns, then we just have an empty list, so initialize here + final_dataset_columns = [] + # If the current running task's input does not have columns defined, or has an empty list of columns + if column_dict is None or len(column_dict) == 0: + # but if it does, then we just copy it over + if incoming_columns is not None and incoming_columns != []: + final_dataset_columns = incoming_columns.copy() + # If the current running task's input does have columns defined + else: + final_dataset_columns = self._convert_ordered_dict_of_columns_to_list(column_dict) + + new_sdt = StructuredDatasetType( + columns=final_dataset_columns, + format=lv.scalar.structured_dataset.metadata.structured_dataset_type.format, + external_schema_type=lv.scalar.structured_dataset.metadata.structured_dataset_type.external_schema_type, + external_schema_bytes=lv.scalar.structured_dataset.metadata.structured_dataset_type.external_schema_bytes, + ) + metad = StructuredDatasetMetadata(structured_dataset_type=new_sdt) + + # A StructuredDataset type, for example + # t1(input_a: StructuredDataset) # or + # t1(input_a: Annotated[StructuredDataset, my_cols]) + if issubclass(expected_python_type, StructuredDataset): + sd = expected_python_type( + dataframe=None, + # Note here that the type being passed in + metadata=metad, + ) + sd._literal_sd = lv.scalar.structured_dataset + sd.file_format = metad.structured_dataset_type.format + return sd + + # If the requested type was not a StructuredDataset, then it means it was a plain dataframe type, which means + # we should do the opening/downloading and whatever else it might entail right now. No iteration option here. + return self.open_as(ctx, lv.scalar.structured_dataset, df_type=expected_python_type, updated_metadata=metad) + + def to_html(self, ctx: FlyteContext, python_val: typing.Any, expected_python_type: Type[T]) -> str: + if isinstance(python_val, StructuredDataset): + if python_val.dataframe is not None: + df = python_val.dataframe + else: + # Here we only render column information by default instead of opening the structured dataset. + col = typing.cast(StructuredDataset, python_val).columns() + df = pd.DataFrame(col, ["column type"]) + return df.to_html() # type: ignore + else: + df = python_val + + if type(df) in self.Renderers: + return self.Renderers[type(df)].to_html(df) + else: + raise NotImplementedError(f"Could not find a renderer for {type(df)} in {self.Renderers}") + + def open_as( + self, + ctx: FlyteContext, + sd: literals.StructuredDataset, + df_type: Type[DF], + updated_metadata: StructuredDatasetMetadata, + ) -> DF: + """ + :param ctx: A FlyteContext, useful in accessing the filesystem and other attributes + :param sd: + :param df_type: + :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 = 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): + raise ValueError(f"Decoder {decoder} returned iterator {result} but whole value requested from {sd}") + return result + + def iter_as( + self, + ctx: FlyteContext, + sd: literals.StructuredDataset, + df_type: Type[DF], + updated_metadata: StructuredDatasetMetadata, + ) -> typing.Iterator[DF]: + protocol = get_protocol(sd.uri) + decoder = self.DECODERS[df_type][protocol][sd.metadata.structured_dataset_type.format] + result: Union[DF, typing.Iterator[DF]] = decoder.decode(ctx, sd, updated_metadata) + if not isinstance(result, types.GeneratorType): + raise ValueError(f"Decoder {decoder} didn't return iterator {result} but should have from {sd}") + return result + + def _get_dataset_column_literal_type(self, t: Type) -> type_models.LiteralType: + 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: + return type_models.LiteralType(map_value_type=self._get_dataset_column_literal_type(t.__args__[1])) + raise AssertionError(f"type {t} is currently not supported by StructuredDataset") + + def _convert_ordered_dict_of_columns_to_list( + self, column_map: typing.Optional[typing.OrderedDict[str, Type]] + ) -> typing.List[StructuredDatasetType.DatasetColumn]: + converted_cols: typing.List[StructuredDatasetType.DatasetColumn] = [] + if column_map is None or len(column_map) == 0: + return converted_cols + for k, v in column_map.items(): + lt = self._get_dataset_column_literal_type(v) + converted_cols.append(StructuredDatasetType.DatasetColumn(name=k, literal_type=lt)) + return converted_cols + + def _get_dataset_type(self, t: typing.Union[Type[StructuredDataset], typing.Any]) -> StructuredDatasetType: + original_python_type, column_map, storage_format, pa_schema = extract_cols_and_format(t) # type: ignore + + # Get the column information + converted_cols: typing.List[ + StructuredDatasetType.DatasetColumn + ] = self._convert_ordered_dict_of_columns_to_list(column_map) + + return StructuredDatasetType( + columns=converted_cols, + format=storage_format, + external_schema_type="arrow" if pa_schema else None, + external_schema_bytes=typing.cast(pa.lib.Schema, pa_schema).to_string().encode() if pa_schema else None, + ) + + def get_literal_type(self, t: typing.Union[Type[StructuredDataset], typing.Any]) -> LiteralType: + """ + Provide a concrete implementation so that writers of custom dataframe handlers since there's nothing that + special about the literal type. Any dataframe type will always be associated with the structured dataset type. + The other aspects of it - columns, external schema type, etc. can be read from associated metadata. + + :param t: The python dataframe type, which is mostly ignored. + """ + return LiteralType(structured_dataset_type=self._get_dataset_type(t)) + + def guess_python_type(self, literal_type: LiteralType) -> Type[StructuredDataset]: + # todo: technically we should return the dataframe type specified in the constructor, but to do that, + # we'd have to store that, which we don't do today. See possibly #1363 + if literal_type.structured_dataset_type is not None: + return StructuredDataset + raise ValueError(f"StructuredDatasetTransformerEngine cannot reverse {literal_type}") + + +flyte_dataset_transformer = StructuredDatasetTransformerEngine() +TypeEngine.register(flyte_dataset_transformer) diff --git a/flytekit/flytekit_scripts/Readme.rst b/flytekit/flytekit_scripts/Readme.rst new file mode 100644 index 0000000000..e3f8787f82 --- /dev/null +++ b/flytekit/flytekit_scripts/Readme.rst @@ -0,0 +1,4 @@ +Flyte Tools +========================== + +This folder contains stuff that isn't strictly related to the SDK, but will be used by end-users. diff --git a/flytekit/flytekit_scripts/flytekit_build_image.sh b/flytekit/flytekit_scripts/flytekit_build_image.sh new file mode 100755 index 0000000000..8ddfa54358 --- /dev/null +++ b/flytekit/flytekit_scripts/flytekit_build_image.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -e + +# NB: This script is bundled with the FlyteKit SDK and is the recommended way for users to build their images. +# This build script ensures that the resulting workflow image that the Flyte ecosystem executes is built +# in a standardized way, complete with all the environment variables that the rest of the system will rely on. + +if [ -z "$1" ]; then + echo usage: ./flytekit_build_image.sh [/path/to/Dockerfile or /path/to/repo] [optional tag prefix] + exit +fi + +echo "" +echo "------------------------------------" +echo " DOCKER BUILD" +echo "------------------------------------" +echo "" + +DIRPATH="" +DOCKERFILE_PATH="" +if [ -d "$1" ]; then + DIRPATH=$(cd "$1" && pwd) + DOCKERFILE_PATH=${DIRPATH}/"Dockerfile" +elif [ -f "$1" ]; then + DIRPATH=$(cd "$(dirname "$1")" && pwd) + DOCKERFILE_PATH=${DIRPATH}/$(basename "$1") +else + exit 1 +fi + +PREFIX="$2-" +if [ -z "$2" ]; then + PREFIX="" +fi + +# Go into the directory representing the user's repo +pushd "${DIRPATH}" + +# Grab the repo name from the argument if not already defined +# Note that this repo name will be the name of the Docker image. +if [ -z "${IMAGE_NAME}" ]; then + IMAGE_NAME=${PWD##*/} +fi + +# Do not do anything if there are unstaged git changes +CHANGED=$(git status --porcelain) +if [ -n "$CHANGED" ]; then + echo "Please commit git changes before building" + exit 1 +fi + +# set default tag to the latest git SHA +if [ -z "$TAG" ]; then + TAG=$(git rev-parse HEAD) +fi + +# If the registry does not exist, don't worry about it, and let users build locally +# This is the name of the image that will be injected into the container as an environment variable +if [ -n "$REGISTRY" ]; then + FLYTE_INTERNAL_IMAGE=${REGISTRY}/${IMAGE_NAME}:${PREFIX}${TAG} +else + FLYTE_INTERNAL_IMAGE=${IMAGE_NAME}:${PREFIX}${TAG} +fi + +DOCKER_PLATFORM_OPT=() +# Check if the user set the target build architecture, if not use the default instead. +if [ -n "$TARGET_PLATFORM_BUILD" ]; then + DOCKER_PLATFORM_OPT=(--platform "$TARGET_PLATFORM_BUILD") +else + TARGET_PLATFORM_BUILD="default" +fi + +echo "Building: $FLYTE_INTERNAL_IMAGE for $TARGET_PLATFORM_BUILD architecture" + +# This build command is the raison d'etre of this script, it ensures that the version is injected into the image itself +docker build . "${DOCKER_PLATFORM_OPT[@]}" --build-arg tag="${FLYTE_INTERNAL_IMAGE}" -t "${FLYTE_INTERNAL_IMAGE}" -f "${DOCKERFILE_PATH}" + +echo "$IMAGE_NAME built locally." + +# Create the appropriate tags +echo "${FLYTE_INTERNAL_IMAGE} tagged" +if [ -n "$REGISTRY" ]; then + # Also push if there's a registry to push to + if [[ "${REGISTRY}" == "docker.io"* && -z "${NOPUSH}" ]]; then + docker login --username="${DOCKERHUB_USERNAME}" --password="${DOCKERHUB_PASSWORD}" + fi + + if [[ -z "${NOPUSH}" ]]; then + docker push "${FLYTE_INTERNAL_IMAGE}" + echo "${FLYTE_INTERNAL_IMAGE} pushed to remote" + fi +fi + +popd + +echo "" +echo "------------------------------------" +echo " SUCCESS" +echo "------------------------------------" +echo "" diff --git a/flytekit/flytekit_scripts/flytekit_venv b/flytekit/flytekit_scripts/flytekit_venv new file mode 100644 index 0000000000..fae6e3618a --- /dev/null +++ b/flytekit/flytekit_scripts/flytekit_venv @@ -0,0 +1,10 @@ +#!/bin/bash + +# Our SDK entrypoint can be configured to call this script +# This script maps that command to the conventional location of the virtual environment in Flyte containers + +set -e + +. ${VENV}/bin/activate + +exec $* diff --git a/flytekit/plugins/Makefile b/flytekit/plugins/Makefile new file mode 100644 index 0000000000..9131836c77 --- /dev/null +++ b/flytekit/plugins/Makefile @@ -0,0 +1,25 @@ +.PHONY: test +test: + find . -maxdepth 1 -type d | grep 'flytekit-' | xargs -L1 pytest + +.PHONY: build_all_plugins +build_all_plugins: + ./run_all_plugins.sh python setup.py sdist bdist_wheel + +.PHONY: publish_all_plugins +publish_all_plugins: + twine upload */dist/* + +.PHONY: all_requirements +all_requirements: + ./run_all_plugins.sh pip-compile requirements.in --upgrade --verbose + +PLACEHOLDER := "__version__\ =\ \"0.0.0+develop\"" +VERSION_FILE := "setup.py" + +.PHONY: update_all_versions +update_all_versions: + # ensure the placeholder is there. If grep doesn't find the placeholder + # it exits with exit code 1 and github actions aborts the build. + ./run_all_plugins.sh grep "$(PLACEHOLDER)" "$(VERSION_FILE)" + ./run_all_plugins.sh sed -i "s/$(PLACEHOLDER)/__version__ = \"${VERSION}\"/g" $(VERSION_FILE) diff --git a/flytekit/plugins/README.md b/flytekit/plugins/README.md new file mode 100644 index 0000000000..d738c5b5a4 --- /dev/null +++ b/flytekit/plugins/README.md @@ -0,0 +1,150 @@ +# Flytekit Python Plugins + +All the Flytekit plugins maintained by the core team are added here. It is not necessary to add plugins here, but this is a good starting place. + +## Currently Available Plugins 🔌 + +| Plugin | Installation | Description | Version | Type | +|------------------------------|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| AWS Sagemaker Training | ```bash pip install flytekitplugins-awssagemaker ``` | Installs SDK to author Sagemaker built-in and custom training jobs in python | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-awssagemaker.svg)](https://pypi.python.org/pypi/flytekitplugins-awssagemaker/) | Backend | +| dask | ```bash pip install flytekitplugins-dask ``` | Installs SDK to author dask jobs that can be executed natively on Kubernetes using the Flyte backend plugin | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-awssagemaker.svg)](https://pypi.python.org/pypi/flytekitplugins-dask/) | Backend | +| Hive Queries | ```bash pip install flytekitplugins-hive ``` | Installs SDK to author Hive Queries that can be executed on a configured hive backend using Flyte backend plugin | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-hive.svg)](https://pypi.python.org/pypi/flytekitplugins-hive/) | Backend | +| K8s distributed PyTorch Jobs | ```bash pip install flytekitplugins-kfpytorch ``` | Installs SDK to author Distributed pyTorch Jobs in python using Kubeflow PyTorch Operator | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-kfpytorch.svg)](https://pypi.python.org/pypi/flytekitplugins-kfpytorch/) | Backend | +| K8s native tensorflow Jobs | ```bash pip install flytekitplugins-kftensorflow ``` | Installs SDK to author Distributed tensorflow Jobs in python using Kubeflow Tensorflow Operator | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-kftensorflow.svg)](https://pypi.python.org/pypi/flytekitplugins-kftensorflow/) | Backend | +| K8s native MPI Jobs | ```bash pip install flytekitplugins-kfmpi ``` | Installs SDK to author Distributed MPI Jobs in python using Kubeflow MPI Operator | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-kfmpi.svg)](https://pypi.python.org/pypi/flytekitplugins-kfmpi/) | Backend | +| Papermill based Tasks | ```bash pip install flytekitplugins-papermill ``` | Execute entire notebooks as Flyte Tasks and pass inputs and outputs between them and python tasks | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-papermill.svg)](https://pypi.python.org/pypi/flytekitplugins-papermill/) | Flytekit-only | +| Pod Tasks | ```bash pip install flytekitplugins-pod ``` | Installs SDK to author Pods in python. These pods can have multiple containers, use volumes and have non exiting side-cars | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-pod.svg)](https://pypi.python.org/pypi/flytekitplugins-pod/) | Flytekit-only | +| spark | ```bash pip install flytekitplugins-spark ``` | Installs SDK to author Spark jobs that can be executed natively on Kubernetes with a supported backend Flyte plugin | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-spark.svg)](https://pypi.python.org/pypi/flytekitplugins-spark/) | Backend | +| AWS Athena Queries | ```bash pip install flytekitplugins-athena ``` | Installs SDK to author queries executed on AWS Athena | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-athena.svg)](https://pypi.python.org/pypi/flytekitplugins-athena/) | Backend | +| DOLT | ```bash pip install flytekitplugins-dolt ``` | Read & write dolt data sets and use dolt tables as native types | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-dolt.svg)](https://pypi.python.org/pypi/flytekitplugins-dolt/) | Flytekit-only | +| Pandera | ```bash pip install flytekitplugins-pandera ``` | Use Pandera schemas as native Flyte types, which enable data quality checks. | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-pandera.svg)](https://pypi.python.org/pypi/flytekitplugins-pandera/) | Flytekit-only | +| SQLAlchemy | ```bash pip install flytekitplugins-sqlalchemy ``` | Write queries for any database that supports SQLAlchemy | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-sqlalchemy.svg)](https://pypi.python.org/pypi/flytekitplugins-sqlalchemy/) | Flytekit-only | +| Great Expectations | ```bash pip install flytekitplugins-great-expectations``` | Enforce data quality for various data types within Flyte | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-great-expectations.svg)](https://pypi.python.org/pypi/flytekitplugins-great-expectations/) | Flytekit-only | +| Snowflake | ```bash pip install flytekitplugins-snowflake``` | Use Snowflake as a 'data warehouse-as-a-service' within Flyte | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-snowflake.svg)](https://pypi.python.org/pypi/flytekitplugins-snowflake/) | Backend | +| dbt | ```bash pip install flytekitplugins-dbt``` | Run dbt within Flyte | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-dbt.svg)](https://pypi.python.org/pypi/flytekitplugins-dbt/) | Flytekit-only | +| Huggingface | ```bash pip install flytekitplugins-huggingface``` | Read & write Hugginface Datasets as Flyte StructuredDatasets | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-huggingface.svg)](https://pypi.python.org/pypi/flytekitplugins-huggingface/) | Flytekit-only | +| DuckDB | ```bash pip install flytekitplugins-duckdb``` | Run analytical workloads with ease using DuckDB | [![PyPI version fury.io](https://badge.fury.io/py/flytekitplugins-duckdb.svg)](https://pypi.python.org/pypi/flytekitplugins-duckdb/) | Flytekit-only | + +## Have a Plugin Idea? 💡 +Please [file an issue](https://github.com/flyteorg/flyte/issues/new?assignees=&labels=untriaged%2Cplugins&template=backend-plugin-request.md&title=%5BPlugin%5D). + +## Development 💻 +Flytekit plugins are structured as micro-libs and can be authored in an independent repository. + +> Refer to the [Python microlibs](https://medium.com/@jherreras/python-microlibs-5be9461ad979) blog to understand the idea of microlibs. + +The plugins maintained by the core team can be found in this repository and provide a simple way of discovery. + +## Unit tests 🧪 +Plugins should have their own unit tests. + +## Guidelines 📜 +Some guidelines to help you write the Flytekit plugins better. + +1. The folder name has to be `flytekit-*`, e.g., `flytekit-hive`. In case you want to group for a specific service, then use `flytekit-aws-athena`. +2. Flytekit plugins use a concept called [Namespace packages](https://packaging.python.org/guides/creating-and-discovering-plugins/#using-namespace-packages), and thus, the package structure is essential. + + Please use the following Python package structure: + ``` + flytekit-myplugin/ + - README.md + - setup.py + - flytekitplugins/ + - myplugin/ + - __init__.py + - tests + - __init__.py + ``` + *NOTE:* the inner package `flytekitplugins` DOES NOT have an `__init__.py` file. + +3. The published packages have to be named `flytekitplugins-{package-name}`, where `{package-name}` is a unique identifier for the plugin. + +4. The setup.py file has to have the following template. You can use it as is by editing the TODO sections. + +```python +from setuptools import setup + +# TODO put the plugin name here +PLUGIN_NAME = "" + +# TODO decide if the plugin is regular or `data` +# for regular plugins +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" +# For data/persistence plugins +# microlib_name = f"flytekitplugins-data-{PLUGIN_NAME}" + +# TODO add additional requirements +plugin_requires = ["flytekit>=1.1.0b0,<2.0.0, ""] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + # TODO Edit the description + description="My awesome plugin.....", + # TODO alter the last part of the following URL + url="https://github.com/flyteorg/flytekit/tree/master/plugins/flytekit-...", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + # TODO OPTIONAL + # FOR Plugins where auto-loading on installation is desirable, please uncomment this line and ensure that the + # __init__.py has the right modules available to be loaded, or point to the right module + # entry_points={"flytekit.plugins": [f"{PLUGIN_NAME}=flytekitplugins.{PLUGIN_NAME}"]}, +) +``` +5. Each plugin should have a README.md, which describes how to install it with a simple example. For example, refer to flytekit-greatexpectations' [README](./flytekit-greatexpectations/README.md). + +6. Each plugin should have its own tests' package. *NOTE:* `tests` folder should have an `__init__.py` file. + +7. There may be some cases where you might want to auto-load some of your modules when the plugin is installed. This is especially true for `data-plugins` and `type-plugins`. +In such a case, you can add a special directive in the `setup.py` which will instruct Flytekit to automatically load the prescribed modules. + + Following shows an excerpt from the `flytekit-data-fsspec` plugin's setup.py file. + + ```python + setup( + entry_points={"flytekit.plugins": [f"{PLUGIN_NAME}=flytekitplugins.{PLUGIN_NAME}"]}, + ) + + ``` + +### Flytekit Version Pinning +Currently we advocate pinning to minor releases of flytekit. To bump the pins across the board, `cd plugins/` and then +update the command below with the appropriate range and run + +```bash +for f in $(ls **/setup.py); do sed -i "s/flytekit>.*,<1.1/flytekit>=1.1.0b0,<1.2/" $f; done +``` + +Try using `gsed` instead of `sed` if you are on a Mac. Also this only works of course for setup files that start with the version in your sed command. There may be plugins that have different pins to start out with. + +## References 📚 +- Example of a simple Python task that allows adding only Python side functionality: [flytekit-greatexpectations](./flytekit-greatexpectations/) +- Example of a TypeTransformer or a Type Plugin: [flytekit-pandera](./flytekit-pandera/). These plugins add new types to Flyte and tell Flyte how to transform them and add additional features through types. Flyte is a multi-lang system, and type transformers allow marshaling between Flytekit and backend and other languages. +- Example of TaskTemplate plugin which also allows plugin writers to supply a prebuilt container for runtime: [flytekit-sqlalchemy](./flytekit-sqlalchemy/) +- Example of a SQL backend plugin where the actual query invocation is done by a backend plugin: [flytekit-snowflake](./flytekit-snowflake/) +- Example of a Meta plugin that can wrap other tasks: [flytekit-papermill](./flytekit-papermill/) +- Example of a plugin that modifies the execution command: [flytekit-spark](./flytekit-spark/) OR [flytekit-aws-sagemaker](./flytekit-aws-sagemaker/) +- Example that allows executing the user container with some other context modifications: [flytekit-kf-tensorflow](./flytekit-kf-tensorflow/) +- Example of a Persistence Plugin that allows data to be stored to different persistence layers: [flytekit-data-fsspec](./flytekit-data-fsspec/) diff --git a/flytekit/plugins/__init__.py b/flytekit/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-airflow/README.md b/flytekit/plugins/flytekit-airflow/README.md new file mode 100644 index 0000000000..52bc8790e0 --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/README.md @@ -0,0 +1,33 @@ +# Flytekit Airflow Plugin +Airflow plugin allows you to seamlessly run Airflow tasks in the Flyte workflow without changing any code. + +- Compile Airflow tasks to Flyte tasks +- Use Airflow sensors/operators in Flyte workflows +- Add support running Airflow tasks locally without running a cluster + +## Example +```python +from airflow.sensors.filesystem import FileSensor +from flytekit import task, workflow + +@task() +def t1(): + print("flyte") + + +@workflow +def wf(): + sensor = FileSensor(task_id="id", filepath="/tmp/1234") + sensor >> t1() + + +if __name__ == '__main__': + wf() +``` + + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-airflow +``` diff --git a/flytekit/plugins/flytekit-airflow/dev-requirements.in b/flytekit/plugins/flytekit-airflow/dev-requirements.in new file mode 100644 index 0000000000..b3913ef810 --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/dev-requirements.in @@ -0,0 +1,2 @@ +apache-airflow-providers-apache-beam[google] +apache-airflow[google] diff --git a/flytekit/plugins/flytekit-airflow/dev-requirements.txt b/flytekit/plugins/flytekit-airflow/dev-requirements.txt new file mode 100644 index 0000000000..114279f520 --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/dev-requirements.txt @@ -0,0 +1,986 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile dev-requirements.in +# +aiofiles==23.2.1 + # via gcloud-aio-storage +aiohttp==3.9.2 + # via + # apache-airflow-providers-http + # gcloud-aio-auth + # gcsfs +aiosignal==1.3.1 + # via aiohttp +alembic==1.12.1 + # via + # apache-airflow + # sqlalchemy-spanner +annotated-types==0.6.0 + # via pydantic +anyio==4.0.0 + # via httpx +apache-airflow[google]==2.8.0 + # via + # -r dev-requirements.in + # apache-airflow-providers-apache-beam + # apache-airflow-providers-common-sql + # apache-airflow-providers-ftp + # apache-airflow-providers-google + # apache-airflow-providers-http + # apache-airflow-providers-imap + # apache-airflow-providers-sqlite +apache-airflow-providers-apache-beam[google]==5.3.0 + # via -r dev-requirements.in +apache-airflow-providers-common-sql==1.8.0 + # via + # apache-airflow + # apache-airflow-providers-google + # apache-airflow-providers-sqlite +apache-airflow-providers-ftp==3.6.0 + # via apache-airflow +apache-airflow-providers-google==10.11.0 + # via + # apache-airflow + # apache-airflow-providers-apache-beam +apache-airflow-providers-http==4.6.0 + # via apache-airflow +apache-airflow-providers-imap==3.4.0 + # via apache-airflow +apache-airflow-providers-sqlite==3.5.0 + # via apache-airflow +apache-beam[gcp]==2.51.0 + # via apache-airflow-providers-apache-beam +apispec[yaml]==6.3.0 + # via + # apispec + # flask-appbuilder +argcomplete==3.1.4 + # via apache-airflow +asgiref==3.7.2 + # via + # apache-airflow + # apache-airflow-providers-google + # apache-airflow-providers-http +async-timeout==4.0.3 + # via aiohttp +attrs==23.1.0 + # via + # aiohttp + # apache-airflow + # cattrs + # jsonschema + # looker-sdk + # referencing +babel==2.13.1 + # via flask-babel +backoff==2.2.1 + # via + # gcloud-aio-auth + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +blinker==1.7.0 + # via apache-airflow +cachelib==0.9.0 + # via + # flask-caching + # flask-session +cachetools==5.3.2 + # via + # apache-beam + # google-auth +cattrs==23.1.2 + # via looker-sdk +certifi==2023.7.22 + # via + # httpcore + # httpx + # requests +cffi==1.16.0 + # via cryptography +chardet==5.2.0 + # via gcloud-aio-auth +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # clickclick + # flask + # flask-appbuilder +clickclick==20.10.2 + # via connexion +cloudpickle==2.2.1 + # via apache-beam +colorama==0.4.6 + # via flask-appbuilder +colorlog==4.8.0 + # via apache-airflow +configupdater==3.1.1 + # via apache-airflow +connexion[flask]==2.14.2 + # via + # apache-airflow + # connexion +crcmod==1.7 + # via apache-beam +cron-descriptor==1.4.0 + # via apache-airflow +croniter==2.0.1 + # via apache-airflow +cryptography==41.0.6 + # via + # apache-airflow + # gcloud-aio-auth + # pyopenssl +db-dtypes==1.1.1 + # via pandas-gbq +decorator==5.1.1 + # via gcsfs +deprecated==1.2.14 + # via + # apache-airflow + # limits + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +dill==0.3.1.1 + # via + # apache-airflow + # apache-beam +dnspython==2.4.2 + # via + # email-validator + # pymongo +docopt==0.6.2 + # via hdfs +docutils==0.20.1 + # via python-daemon +email-validator==1.3.1 + # via flask-appbuilder +exceptiongroup==1.1.3 + # via + # anyio + # cattrs +fastavro==1.9.0 + # via apache-beam +fasteners==0.19 + # via + # apache-beam + # google-apitools +flask==2.2.5 + # via + # apache-airflow + # connexion + # flask-appbuilder + # flask-babel + # flask-caching + # flask-jwt-extended + # flask-limiter + # flask-login + # flask-session + # flask-sqlalchemy + # flask-wtf +flask-appbuilder==4.3.10 + # via apache-airflow +flask-babel==2.0.0 + # via flask-appbuilder +flask-caching==2.1.0 + # via apache-airflow +flask-jwt-extended==4.5.3 + # via flask-appbuilder +flask-limiter==3.5.0 + # via flask-appbuilder +flask-login==0.6.3 + # via + # apache-airflow + # flask-appbuilder +flask-session==0.5.0 + # via apache-airflow +flask-sqlalchemy==2.5.1 + # via flask-appbuilder +flask-wtf==1.2.1 + # via + # apache-airflow + # flask-appbuilder +frozenlist==1.4.0 + # via + # aiohttp + # aiosignal +fsspec==2023.10.0 + # via + # apache-airflow + # gcsfs + # universal-pathlib +gcloud-aio-auth==4.2.3 + # via + # apache-airflow-providers-google + # gcloud-aio-bigquery + # gcloud-aio-storage +gcloud-aio-bigquery==7.0.0 + # via apache-airflow-providers-google +gcloud-aio-storage==9.0.0 + # via apache-airflow-providers-google +gcsfs==2023.10.0 + # via apache-airflow-providers-google +google-ads==22.1.0 + # via apache-airflow-providers-google +google-api-core[grpc]==2.13.0 + # via + # apache-airflow-providers-google + # apache-beam + # google-ads + # google-api-python-client + # google-cloud-aiplatform + # google-cloud-appengine-logging + # google-cloud-automl + # google-cloud-batch + # google-cloud-bigquery + # google-cloud-bigquery-datatransfer + # google-cloud-bigquery-storage + # google-cloud-bigtable + # google-cloud-build + # google-cloud-compute + # google-cloud-container + # google-cloud-core + # google-cloud-datacatalog + # google-cloud-dataflow-client + # google-cloud-dataform + # google-cloud-dataplex + # google-cloud-dataproc + # google-cloud-dataproc-metastore + # google-cloud-datastore + # google-cloud-dlp + # google-cloud-kms + # google-cloud-language + # google-cloud-logging + # google-cloud-memcache + # google-cloud-monitoring + # google-cloud-orchestration-airflow + # google-cloud-os-login + # google-cloud-pubsub + # google-cloud-pubsublite + # google-cloud-recommendations-ai + # google-cloud-redis + # google-cloud-resource-manager + # google-cloud-run + # google-cloud-secret-manager + # google-cloud-spanner + # google-cloud-speech + # google-cloud-storage + # google-cloud-storage-transfer + # google-cloud-tasks + # google-cloud-texttospeech + # google-cloud-translate + # google-cloud-videointelligence + # google-cloud-vision + # google-cloud-workflows + # pandas-gbq + # sqlalchemy-bigquery +google-api-python-client==2.107.0 + # via apache-airflow-providers-google +google-apitools==0.5.31 + # via apache-beam +google-auth==2.23.4 + # via + # apache-airflow-providers-google + # apache-beam + # gcsfs + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-auth-oauthlib + # google-cloud-core + # google-cloud-storage + # pandas-gbq + # pydata-google-auth + # sqlalchemy-bigquery +google-auth-httplib2==0.1.1 + # via + # apache-airflow-providers-google + # apache-beam + # google-api-python-client +google-auth-oauthlib==1.1.0 + # via + # gcsfs + # google-ads + # pandas-gbq + # pydata-google-auth +google-cloud-aiplatform==1.36.1 + # via + # apache-airflow-providers-google + # apache-beam +google-cloud-appengine-logging==1.3.2 + # via google-cloud-logging +google-cloud-audit-log==0.2.5 + # via google-cloud-logging +google-cloud-automl==2.11.3 + # via apache-airflow-providers-google +google-cloud-batch==0.17.3 + # via apache-airflow-providers-google +google-cloud-bigquery==3.13.0 + # via + # apache-beam + # google-cloud-aiplatform + # pandas-gbq + # sqlalchemy-bigquery +google-cloud-bigquery-datatransfer==3.12.1 + # via apache-airflow-providers-google +google-cloud-bigquery-storage==2.22.0 + # via + # apache-beam + # pandas-gbq +google-cloud-bigtable==2.21.0 + # via + # apache-airflow-providers-google + # apache-beam +google-cloud-build==3.21.0 + # via apache-airflow-providers-google +google-cloud-compute==1.14.1 + # via apache-airflow-providers-google +google-cloud-container==2.33.0 + # via apache-airflow-providers-google +google-cloud-core==2.3.3 + # via + # apache-beam + # google-cloud-bigquery + # google-cloud-bigtable + # google-cloud-datastore + # google-cloud-logging + # google-cloud-spanner + # google-cloud-storage + # google-cloud-translate +google-cloud-datacatalog==3.16.0 + # via apache-airflow-providers-google +google-cloud-dataflow-client==0.8.5 + # via apache-airflow-providers-google +google-cloud-dataform==0.5.4 + # via apache-airflow-providers-google +google-cloud-dataplex==1.8.1 + # via apache-airflow-providers-google +google-cloud-dataproc==5.7.0 + # via apache-airflow-providers-google +google-cloud-dataproc-metastore==1.13.0 + # via apache-airflow-providers-google +google-cloud-datastore==2.18.0 + # via apache-beam +google-cloud-dlp==3.13.0 + # via + # apache-airflow-providers-google + # apache-beam +google-cloud-kms==2.19.2 + # via apache-airflow-providers-google +google-cloud-language==2.11.1 + # via + # apache-airflow-providers-google + # apache-beam +google-cloud-logging==3.8.0 + # via apache-airflow-providers-google +google-cloud-memcache==1.7.3 + # via apache-airflow-providers-google +google-cloud-monitoring==2.16.0 + # via apache-airflow-providers-google +google-cloud-orchestration-airflow==1.9.2 + # via apache-airflow-providers-google +google-cloud-os-login==2.11.0 + # via apache-airflow-providers-google +google-cloud-pubsub==2.18.4 + # via + # apache-airflow-providers-google + # apache-beam + # google-cloud-pubsublite +google-cloud-pubsublite==1.8.3 + # via apache-beam +google-cloud-recommendations-ai==0.10.5 + # via apache-beam +google-cloud-redis==2.13.2 + # via apache-airflow-providers-google +google-cloud-resource-manager==1.10.4 + # via google-cloud-aiplatform +google-cloud-run==0.10.0 + # via apache-airflow-providers-google +google-cloud-secret-manager==2.16.4 + # via apache-airflow-providers-google +google-cloud-spanner==3.40.1 + # via + # apache-airflow-providers-google + # apache-beam + # sqlalchemy-spanner +google-cloud-speech==2.22.0 + # via apache-airflow-providers-google +google-cloud-storage==2.13.0 + # via + # apache-airflow-providers-google + # gcsfs + # google-cloud-aiplatform +google-cloud-storage-transfer==1.9.2 + # via apache-airflow-providers-google +google-cloud-tasks==2.14.2 + # via apache-airflow-providers-google +google-cloud-texttospeech==2.14.2 + # via apache-airflow-providers-google +google-cloud-translate==3.12.1 + # via apache-airflow-providers-google +google-cloud-videointelligence==2.11.4 + # via + # apache-airflow-providers-google + # apache-beam +google-cloud-vision==3.4.5 + # via + # apache-airflow-providers-google + # apache-beam +google-cloud-workflows==1.12.1 + # via apache-airflow-providers-google +google-crc32c==1.5.0 + # via + # google-cloud-storage + # google-resumable-media +google-re2==1.1 + # via apache-airflow +google-resumable-media==2.6.0 + # via + # google-cloud-bigquery + # google-cloud-storage +googleapis-common-protos[grpc]==1.61.0 + # via + # google-ads + # google-api-core + # google-cloud-audit-log + # grpc-google-iam-v1 + # grpcio-status + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +graphviz==0.20.1 + # via apache-airflow +greenlet==3.0.1 + # via sqlalchemy +grpc-google-iam-v1==0.12.6 + # via + # google-cloud-bigtable + # google-cloud-build + # google-cloud-datacatalog + # google-cloud-dataform + # google-cloud-dataplex + # google-cloud-dataproc + # google-cloud-dataproc-metastore + # google-cloud-kms + # google-cloud-logging + # google-cloud-pubsub + # google-cloud-resource-manager + # google-cloud-run + # google-cloud-secret-manager + # google-cloud-spanner + # google-cloud-tasks +grpcio==1.59.2 + # via + # apache-beam + # google-ads + # google-api-core + # google-cloud-bigquery + # google-cloud-pubsub + # google-cloud-pubsublite + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-gcp + # grpcio-status + # opentelemetry-exporter-otlp-proto-grpc +grpcio-gcp==0.2.2 + # via apache-airflow-providers-google +grpcio-status==1.59.2 + # via + # google-ads + # google-api-core + # google-cloud-pubsub + # google-cloud-pubsublite +gunicorn==21.2.0 + # via apache-airflow +h11==0.14.0 + # via httpcore +hdfs==2.7.3 + # via apache-beam +httpcore==1.0.1 + # via httpx +httplib2==0.22.0 + # via + # apache-beam + # google-api-python-client + # google-apitools + # google-auth-httplib2 + # oauth2client +httpx==0.25.1 + # via + # apache-airflow + # apache-airflow-providers-google +idna==3.4 + # via + # anyio + # email-validator + # httpx + # requests + # yarl +importlib-metadata==6.8.0 + # via opentelemetry-api +importlib-resources==6.1.1 + # via limits +inflection==0.5.1 + # via connexion +itsdangerous==2.1.2 + # via + # apache-airflow + # connexion + # flask + # flask-wtf +jinja2==3.1.3 + # via + # apache-airflow + # flask + # flask-babel + # python-nvd3 +js2py==0.74 + # via apache-beam +json-merge-patch==0.2 + # via apache-airflow-providers-google +jsonschema==4.19.2 + # via + # apache-airflow + # connexion + # flask-appbuilder +jsonschema-specifications==2023.7.1 + # via jsonschema +lazy-object-proxy==1.9.0 + # via apache-airflow +limits==3.6.0 + # via flask-limiter +linkify-it-py==2.0.2 + # via apache-airflow +lockfile==0.12.2 + # via + # apache-airflow + # python-daemon +looker-sdk==23.16.0 + # via apache-airflow-providers-google +mako==1.3.0 + # via alembic +markdown==3.5.1 + # via apache-airflow +markdown-it-py==3.0.0 + # via + # apache-airflow + # mdit-py-plugins + # rich +markupsafe==2.1.3 + # via + # apache-airflow + # jinja2 + # mako + # werkzeug + # wtforms +marshmallow==3.20.1 + # via + # flask-appbuilder + # marshmallow-oneofschema + # marshmallow-sqlalchemy +marshmallow-oneofschema==3.0.1 + # via apache-airflow +marshmallow-sqlalchemy==0.26.1 + # via flask-appbuilder +mdit-py-plugins==0.4.0 + # via apache-airflow +mdurl==0.1.2 + # via markdown-it-py +multidict==6.0.4 + # via + # aiohttp + # yarl +numpy==1.24.4 + # via + # apache-beam + # db-dtypes + # pandas + # pandas-gbq + # pyarrow + # shapely +oauth2client==4.1.3 + # via google-apitools +oauthlib==3.2.2 + # via requests-oauthlib +objsize==0.6.1 + # via apache-beam +opentelemetry-api==1.21.0 + # via + # apache-airflow + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk +opentelemetry-exporter-otlp==1.21.0 + # via apache-airflow +opentelemetry-exporter-otlp-proto-common==1.21.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.21.0 + # via opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-proto-http==1.21.0 + # via opentelemetry-exporter-otlp +opentelemetry-proto==1.21.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.21.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.42b0 + # via opentelemetry-sdk +ordered-set==4.1.0 + # via flask-limiter +orjson==3.9.10 + # via apache-beam +overrides==6.5.0 + # via google-cloud-pubsublite +packaging==23.2 + # via + # apache-airflow + # apache-beam + # apispec + # connexion + # db-dtypes + # google-cloud-aiplatform + # google-cloud-bigquery + # gunicorn + # limits + # marshmallow + # sqlalchemy-bigquery +pandas==2.0.3 + # via + # apache-airflow-providers-google + # db-dtypes + # pandas-gbq +pandas-gbq==0.19.2 + # via apache-airflow-providers-google +pathspec==0.11.2 + # via apache-airflow +pendulum==2.1.2 + # via apache-airflow +pluggy==1.3.0 + # via apache-airflow +prison==0.2.1 + # via flask-appbuilder +proto-plus==1.22.3 + # via + # apache-airflow-providers-google + # apache-beam + # google-ads + # google-cloud-aiplatform + # google-cloud-appengine-logging + # google-cloud-automl + # google-cloud-batch + # google-cloud-bigquery + # google-cloud-bigquery-datatransfer + # google-cloud-bigquery-storage + # google-cloud-bigtable + # google-cloud-build + # google-cloud-compute + # google-cloud-container + # google-cloud-datacatalog + # google-cloud-dataflow-client + # google-cloud-dataform + # google-cloud-dataplex + # google-cloud-dataproc + # google-cloud-dataproc-metastore + # google-cloud-datastore + # google-cloud-dlp + # google-cloud-kms + # google-cloud-language + # google-cloud-logging + # google-cloud-memcache + # google-cloud-monitoring + # google-cloud-orchestration-airflow + # google-cloud-os-login + # google-cloud-pubsub + # google-cloud-recommendations-ai + # google-cloud-redis + # google-cloud-resource-manager + # google-cloud-run + # google-cloud-secret-manager + # google-cloud-spanner + # google-cloud-speech + # google-cloud-storage-transfer + # google-cloud-tasks + # google-cloud-texttospeech + # google-cloud-translate + # google-cloud-videointelligence + # google-cloud-vision + # google-cloud-workflows +protobuf==4.24.4 + # via + # apache-beam + # google-ads + # google-api-core + # google-cloud-aiplatform + # google-cloud-appengine-logging + # google-cloud-audit-log + # google-cloud-automl + # google-cloud-batch + # google-cloud-bigquery + # google-cloud-bigquery-datatransfer + # google-cloud-bigquery-storage + # google-cloud-bigtable + # google-cloud-build + # google-cloud-compute + # google-cloud-container + # google-cloud-datacatalog + # google-cloud-dataflow-client + # google-cloud-dataform + # google-cloud-dataplex + # google-cloud-dataproc + # google-cloud-dataproc-metastore + # google-cloud-datastore + # google-cloud-dlp + # google-cloud-kms + # google-cloud-language + # google-cloud-logging + # google-cloud-memcache + # google-cloud-monitoring + # google-cloud-orchestration-airflow + # google-cloud-os-login + # google-cloud-pubsub + # google-cloud-recommendations-ai + # google-cloud-redis + # google-cloud-resource-manager + # google-cloud-run + # google-cloud-secret-manager + # google-cloud-spanner + # google-cloud-speech + # google-cloud-storage-transfer + # google-cloud-tasks + # google-cloud-texttospeech + # google-cloud-translate + # google-cloud-videointelligence + # google-cloud-vision + # google-cloud-workflows + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status + # opentelemetry-proto + # proto-plus +psutil==5.9.6 + # via apache-airflow +pyarrow==11.0.0 + # via + # apache-beam + # db-dtypes + # pandas-gbq +pyasn1==0.5.0 + # via + # oauth2client + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via + # gcloud-aio-storage + # google-auth + # oauth2client +pycparser==2.21 + # via cffi +pydantic==2.4.2 + # via apache-airflow +pydantic-core==2.10.1 + # via pydantic +pydata-google-auth==1.8.2 + # via pandas-gbq +pydot==1.4.2 + # via apache-beam +pygments==2.16.1 + # via + # apache-airflow + # rich +pyjsparser==2.7.1 + # via js2py +pyjwt==2.8.0 + # via + # apache-airflow + # flask-appbuilder + # flask-jwt-extended + # gcloud-aio-auth +pymongo==4.6.0 + # via apache-beam +pyopenssl==23.3.0 + # via apache-airflow-providers-google +pyparsing==3.1.1 + # via + # httplib2 + # pydot +python-daemon==3.0.1 + # via apache-airflow +python-dateutil==2.8.2 + # via + # apache-airflow + # apache-beam + # croniter + # flask-appbuilder + # google-cloud-bigquery + # pandas + # pendulum +python-nvd3==0.15.0 + # via apache-airflow +python-slugify==8.0.1 + # via + # apache-airflow + # python-nvd3 +pytz==2023.3.post1 + # via + # apache-beam + # croniter + # flask-babel + # pandas +pytzdata==2020.1 + # via pendulum +pyyaml==6.0.1 + # via + # apispec + # clickclick + # connexion + # google-ads +referencing==0.30.2 + # via + # jsonschema + # jsonschema-specifications +regex==2023.10.3 + # via apache-beam +requests==2.31.0 + # via + # apache-airflow-providers-http + # apache-beam + # connexion + # gcsfs + # google-api-core + # google-cloud-bigquery + # google-cloud-storage + # hdfs + # looker-sdk + # opentelemetry-exporter-otlp-proto-http + # requests-oauthlib + # requests-toolbelt +requests-oauthlib==1.3.1 + # via google-auth-oauthlib +requests-toolbelt==1.0.0 + # via apache-airflow-providers-http +rfc3339-validator==0.1.4 + # via apache-airflow +rich==13.6.0 + # via + # apache-airflow + # flask-limiter + # rich-argparse +rich-argparse==1.4.0 + # via apache-airflow +rpds-py==0.12.0 + # via + # jsonschema + # referencing +rsa==4.9 + # via + # gcloud-aio-storage + # google-auth + # oauth2client +setproctitle==1.3.3 + # via apache-airflow +shapely==2.0.2 + # via google-cloud-aiplatform +six==1.16.0 + # via + # google-apitools + # hdfs + # js2py + # oauth2client + # prison + # python-dateutil + # rfc3339-validator +sniffio==1.3.0 + # via + # anyio + # httpx +sqlalchemy==1.4.50 + # via + # alembic + # apache-airflow + # flask-appbuilder + # flask-sqlalchemy + # marshmallow-sqlalchemy + # sqlalchemy-bigquery + # sqlalchemy-jsonfield + # sqlalchemy-spanner + # sqlalchemy-utils +sqlalchemy-bigquery==1.8.0 + # via apache-airflow-providers-google +sqlalchemy-jsonfield==1.0.1.post0 + # via apache-airflow +sqlalchemy-spanner==1.6.2 + # via apache-airflow-providers-google +sqlalchemy-utils==0.41.1 + # via flask-appbuilder +sqlparse==0.4.4 + # via + # apache-airflow-providers-common-sql + # google-cloud-spanner +tabulate==0.9.0 + # via apache-airflow +tenacity==8.2.3 + # via apache-airflow +termcolor==2.3.0 + # via apache-airflow +text-unidecode==1.3 + # via python-slugify +typing-extensions==4.8.0 + # via + # alembic + # apache-airflow + # apache-beam + # asgiref + # cattrs + # flask-limiter + # limits + # looker-sdk + # opentelemetry-sdk + # pydantic + # pydantic-core +tzdata==2023.3 + # via pandas +tzlocal==5.2 + # via js2py +uc-micro-py==1.0.2 + # via linkify-it-py +unicodecsv==0.14.1 + # via apache-airflow +universal-pathlib==0.1.4 + # via apache-airflow +uritemplate==4.1.1 + # via google-api-python-client +urllib3==2.0.7 + # via requests +werkzeug==2.2.3 + # via + # apache-airflow + # connexion + # flask + # flask-appbuilder + # flask-jwt-extended + # flask-login +wrapt==1.15.0 + # via deprecated +wtforms==3.0.1 + # via + # flask-appbuilder + # flask-wtf +yarl==1.9.2 + # via aiohttp +zipp==3.17.0 + # via importlib-metadata +zstandard==0.22.0 + # via apache-beam + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/__init__.py b/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/__init__.py new file mode 100644 index 0000000000..d09cb952cf --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/__init__.py @@ -0,0 +1,16 @@ +""" +.. currentmodule:: flytekitplugins.airflow + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + AirflowConfig + AirflowTask + AirflowAgent +""" + +from .agent import AirflowAgent +from .task import AirflowObj, AirflowTask diff --git a/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/agent.py b/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/agent.py new file mode 100644 index 0000000000..e52453d7bb --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/agent.py @@ -0,0 +1,145 @@ +import asyncio +import typing +from dataclasses import dataclass, field +from typing import Optional + +import cloudpickle +import jsonpickle +from flyteidl.admin.agent_pb2 import ( + CreateTaskResponse, + DeleteTaskResponse, + GetTaskResponse, + Resource, +) +from flyteidl.core.execution_pb2 import TaskExecution +from flytekitplugins.airflow.task import AirflowObj, _get_airflow_instance + +from airflow.exceptions import AirflowException, TaskDeferred +from airflow.models import BaseOperator +from airflow.sensors.base import BaseSensorOperator +from airflow.triggers.base import TriggerEvent +from airflow.utils.context import Context +from flytekit import logger +from flytekit.exceptions.user import FlyteUserException +from flytekit.extend.backend.base_agent import AgentBase, AgentRegistry +from flytekit.models.literals import LiteralMap +from flytekit.models.task import TaskTemplate + + +@dataclass +class ResourceMetadata: + """ + This class is used to store the Airflow task configuration. It is serialized and returned to FlytePropeller. + """ + + airflow_operator: AirflowObj + airflow_trigger: AirflowObj = field(default=None) + airflow_trigger_callback: str = field(default=None) + job_id: typing.Optional[str] = field(default=None) + + +class AirflowAgent(AgentBase): + """ + It is used to run Airflow tasks. It is registered as an agent in the AgentRegistry. + There are three kinds of Airflow tasks: AirflowOperator, AirflowSensor, and AirflowHook. + + Sensor is always invoked in get method. Calling get method to check if the certain condition is met. + For example, FileSensor is used to check if the file exists. If file doesn't exist, agent returns + RUNNING status, otherwise, it returns SUCCEEDED status. + + Hook is a high-level interface to an external platform that lets you quickly and easily talk to + them without having to write low-level code that hits their API or uses special libraries. For example, + SlackHook is used to send messages to Slack. Therefore, Hooks are also invoked in get method. + Note: There is no running state for Hook. It is either successful or failed. + + Operator is invoked in create method. Flytekit will always set deferrable to True for Operator. Therefore, + `operator.execute` will always raise TaskDeferred exception after job is submitted. In the get method, + we create a trigger to check if the job is finished. + Note: some of the operators are not deferrable. For example, BeamRunJavaPipelineOperator, BeamRunPythonPipelineOperator. + In this case, those operators will be converted to AirflowContainerTask and executed in the pod. + """ + + name = "Airflow Agent" + + def __init__(self): + super().__init__(task_type="airflow") + + async def create( + self, + output_prefix: str, + task_template: TaskTemplate, + inputs: Optional[LiteralMap] = None, + **kwargs, + ) -> CreateTaskResponse: + airflow_obj = jsonpickle.decode(task_template.custom["task_config_pkl"]) + airflow_instance = _get_airflow_instance(airflow_obj) + resource_meta = ResourceMetadata(airflow_operator=airflow_obj) + + if isinstance(airflow_instance, BaseOperator) and not isinstance(airflow_instance, BaseSensorOperator): + try: + resource_meta = ResourceMetadata(airflow_operator=airflow_obj) + airflow_instance.execute(context=Context()) + except TaskDeferred as td: + parameters = td.trigger.__dict__.copy() + # Remove parameters that are in the base class + parameters.pop("task_instance", None) + parameters.pop("trigger_id", None) + + resource_meta.airflow_trigger = AirflowObj( + module=td.trigger.__module__, name=td.trigger.__class__.__name__, parameters=parameters + ) + resource_meta.airflow_trigger_callback = td.method_name + + return CreateTaskResponse(resource_meta=cloudpickle.dumps(resource_meta)) + + async def get(self, resource_meta: bytes, **kwargs) -> GetTaskResponse: + meta = cloudpickle.loads(resource_meta) + airflow_operator_instance = _get_airflow_instance(meta.airflow_operator) + airflow_trigger_instance = _get_airflow_instance(meta.airflow_trigger) if meta.airflow_trigger else None + airflow_ctx = Context() + message = None + cur_phase = TaskExecution.RUNNING + + if isinstance(airflow_operator_instance, BaseSensorOperator): + ok = airflow_operator_instance.poke(context=airflow_ctx) + cur_phase = TaskExecution.SUCCEEDED if ok else TaskExecution.RUNNING + elif isinstance(airflow_operator_instance, BaseOperator): + if airflow_trigger_instance: + try: + # Airflow trigger returns immediately when + # 1. Failed to get the task status + # 2. Task succeeded or failed + # succeeded or failed: returns a TriggerEvent with payload + # running: runs forever, so set a default timeout (2 seconds) here. + # failed to get the status: raises AirflowException + event = await asyncio.wait_for(airflow_trigger_instance.run().__anext__(), 2) + try: + # Trigger callback will check the status of the task in the payload, and raise AirflowException if failed. + trigger_callback = getattr(airflow_operator_instance, meta.airflow_trigger_callback) + trigger_callback(context=airflow_ctx, event=typing.cast(TriggerEvent, event).payload) + cur_phase = TaskExecution.SUCCEEDED + except AirflowException as e: + cur_phase = TaskExecution.FAILED + message = e.__str__() + except asyncio.TimeoutError: + logger.debug("No event received from airflow trigger") + except AirflowException as e: + cur_phase = TaskExecution.FAILED + message = e.__str__() + else: + # If there is no trigger, it means the operator is not deferrable. In this case, this operator will be + # executed in the creation step. Therefore, we can directly return to SUCCEEDED here. + # For instance, SlackWebhookOperator is not deferrable. It sends a message to Slack in the creation step. + # If the message is sent successfully, agent will return SUCCEEDED here. Otherwise, it will raise an exception at creation step. + cur_phase = TaskExecution.SUCCEEDED + + else: + raise FlyteUserException("Only sensor and operator are supported.") + + return GetTaskResponse(resource=Resource(phase=cur_phase, message=message)) + + async def delete(self, resource_meta: bytes, **kwargs) -> DeleteTaskResponse: + return DeleteTaskResponse() + + +AgentRegistry.register(AirflowAgent()) diff --git a/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/task.py b/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/task.py new file mode 100644 index 0000000000..17a023dfdb --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/flytekitplugins/airflow/task.py @@ -0,0 +1,231 @@ +import importlib +import logging +import typing +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type + +import jsonpickle + +from flytekit import FlyteContextManager, lazy_module, logger +from flytekit.configuration import SerializationSettings +from flytekit.core.base_task import PythonTask, TaskResolverMixin +from flytekit.core.interface import Interface +from flytekit.core.python_auto_container import PythonAutoContainerTask +from flytekit.core.tracker import TrackedInstance +from flytekit.core.utils import timeit +from flytekit.extend.backend.base_agent import AsyncAgentExecutorMixin + +airflow = lazy_module("airflow") +airflow_models = lazy_module("airflow.models") +airflow_sensors = lazy_module("airflow.sensors.base") +airflow_triggers = lazy_module("airflow.triggers.base") +airflow_context = lazy_module("airflow.utils.context") + + +@dataclass +class AirflowObj(object): + """ + This class is used to store the Airflow task configuration. It is serialized and stored in the Flyte task config. + It can be trigger, hook, operator or sensor. For example: + + from airflow.sensors.filesystem import FileSensor + sensor = FileSensor(task_id="id", filepath="/tmp/1234") + + In this case, the attributes of AirflowObj will be: + module: airflow.sensors.filesystem + name: FileSensor + parameters: {"task_id": "id", "filepath": "/tmp/1234"} + """ + + module: str + name: str + parameters: typing.Dict[str, Any] + + +class AirflowTaskResolver(TrackedInstance, TaskResolverMixin): + """ + This class is used to resolve an Airflow task. It will load an airflow task in the container. + """ + + def name(self) -> str: + return "AirflowTaskResolver" + + @timeit("Load airflow task") + def load_task( + self, loader_args: typing.List[str] + ) -> typing.Union[airflow_models.BaseOperator, airflow_sensors.BaseSensorOperator, airflow_triggers.BaseTrigger]: + """ + This method is used to load an Airflow task. + """ + _, task_module, _, task_name, _, task_config = loader_args + task_module = importlib.import_module(name=task_module) # type: ignore + task_def = getattr(task_module, task_name) + return task_def(name=task_name, task_config=jsonpickle.decode(task_config)) + + def loader_args(self, settings: SerializationSettings, task: PythonAutoContainerTask) -> typing.List[str]: + return [ + "task-module", + task.__module__, + "task-name", + task.__class__.__name__, + "task-config", + jsonpickle.encode(task.task_config), + ] + + def get_all_tasks(self) -> typing.List[PythonAutoContainerTask]: # type: ignore + raise Exception("should not be needed") + + +airflow_task_resolver = AirflowTaskResolver() + + +class AirflowContainerTask(PythonAutoContainerTask[AirflowObj]): + """ + This python container task is used to wrap an Airflow task. It is used to run an Airflow task in a container. + The airflow task module, name and parameters are stored in the task config. + + Some of the Airflow operators are not deferrable, For example, BeamRunJavaPipelineOperator, BeamRunPythonPipelineOperator. + These tasks don't have an async method to get the job status, so cannot be used in the Flyte agent. We run these tasks in a container. + """ + + def __init__( + self, + name: str, + task_config: AirflowObj, + inputs: Optional[Dict[str, Type]] = None, + **kwargs, + ): + super().__init__( + name=name, + task_config=task_config, + interface=Interface(inputs=inputs or {}), + **kwargs, + ) + self._task_resolver = airflow_task_resolver + + def execute(self, **kwargs) -> Any: + logger.info("Executing Airflow task") + _get_airflow_instance(self.task_config).execute(context=airflow_context.Context()) + + +class AirflowTask(AsyncAgentExecutorMixin, PythonTask[AirflowObj]): + """ + This python task is used to wrap an Airflow task. It is used to run an Airflow task in Flyte agent. + The airflow task module, name and parameters are stored in the task config. We run the Airflow task in the agent. + """ + + _TASK_TYPE = "airflow" + + def __init__( + self, + name: str, + task_config: Optional[AirflowObj], + inputs: Optional[Dict[str, Type]] = None, + **kwargs, + ): + super().__init__( + name=name, + task_config=task_config, + interface=Interface(inputs=inputs or {}), + task_type=self._TASK_TYPE, + **kwargs, + ) + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + # Use jsonpickle to serialize the Airflow task config since the return value should be json serializable. + return {"task_config_pkl": jsonpickle.encode(self.task_config)} + + +def _get_airflow_instance( + airflow_obj: AirflowObj +) -> typing.Union[airflow_models.BaseOperator, airflow_sensors.BaseSensorOperator, airflow_triggers.BaseTrigger]: + # Set the GET_ORIGINAL_TASK attribute to True so that obj_def will return the original + # airflow task instead of the Flyte task. + ctx = FlyteContextManager.current_context() + ctx.user_space_params.builder().add_attr("GET_ORIGINAL_TASK", True).build() + + obj_module = importlib.import_module(name=airflow_obj.module) + obj_def = getattr(obj_module, airflow_obj.name) + if _is_deferrable(obj_def): + try: + return obj_def(**airflow_obj.parameters, deferrable=True) + except airflow.exceptions.AirflowException as e: + logger.debug(f"Failed to create operator {airflow_obj.name} with err: {e}.") + logger.debug(f"Airflow operator {airflow_obj.name} does not support deferring.") + + return obj_def(**airflow_obj.parameters) + + +def _is_deferrable(cls: Type) -> bool: + """ + This function is used to check if the Airflow operator is deferrable. + If the operator is not deferrable, we run it in a container instead of the agent. + """ + # Only Airflow operators are deferrable. + if not issubclass(cls, airflow_models.BaseOperator): + return False + # Airflow sensors are not deferrable. The Sensor is a subclass of BaseOperator. + if issubclass(cls, airflow_sensors.BaseSensorOperator): + return False + try: + from airflow.providers.apache.beam.operators.beam import BeamBasePipelineOperator + + # Dataflow operators are not deferrable. + if issubclass(cls, BeamBasePipelineOperator): + return False + except ImportError: + logger.debug("Failed to import BeamBasePipelineOperator") + return True + + +def _flyte_operator(*args, **kwargs): + """ + This function is called by the Airflow operator to create a new task. We intercept this call and return a Flyte + task instead. + """ + cls = args[0] + try: + if FlyteContextManager.current_context().user_space_params.get_original_task: + # Return an original task when running in the agent. + return object.__new__(cls) + except AssertionError: + # This happens when the task is created in the dynamic workflow. + # We don't need to return the original task in this case. + logging.debug("failed to get the attribute GET_ORIGINAL_TASK from user space params") + + container_image = kwargs.pop("container_image", None) + task_id = kwargs.get("task_id", cls.__name__) + config = AirflowObj(module=cls.__module__, name=cls.__name__, parameters=kwargs) + + if not issubclass(cls, airflow_sensors.BaseSensorOperator) and not _is_deferrable(cls): + # Dataflow operators are not deferrable, so we run them in a container. + return AirflowContainerTask(name=task_id, task_config=config, container_image=container_image)() + return AirflowTask(name=task_id, task_config=config)() + + +def _flyte_xcom_push(*args, **kwargs): + """ + This function is called by the Airflow operator to push data to XCom. We intercept this call and store the data + in the Flyte context. + """ + if len(args) < 2: + return + # Store the XCom data in the Flyte context. + # args[0] is the operator instance. + # args[1:] are the XCom data. + # For example, + # op.xcom_push(Context(), "key", "value") + # args[0] is op, args[1:] is [Context(), "key", "value"] + FlyteContextManager.current_context().user_space_params.xcom_data = args[1:] + + +params = FlyteContextManager.current_context().user_space_params +params.builder().add_attr("GET_ORIGINAL_TASK", False).add_attr("XCOM_DATA", {}).build() + +# Monkey patch the Airflow operator. Instead of creating an airflow task, it returns a Flyte task. +airflow_models.BaseOperator.__new__ = _flyte_operator +airflow_models.BaseOperator.xcom_push = _flyte_xcom_push +# Monkey patch the xcom_push method to store the data in the Flyte context. +# Create a dummy DAG to avoid Airflow errors. This DAG is not used. +# TODO: Add support using Airflow DAG in Flyte workflow. We can probably convert the Airflow DAG to a Flyte subworkflow. +airflow_sensors.BaseSensorOperator.dag = airflow.DAG(dag_id="flyte_dag") diff --git a/flytekit/plugins/flytekit-airflow/setup.py b/flytekit/plugins/flytekit-airflow/setup.py new file mode 100644 index 0000000000..682cd72c18 --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup + +PLUGIN_NAME = "airflow" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = [ + "apache-airflow", + "flytekit>=1.9.0", + "flyteidl>=1.10.6", +] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package holds the Airflow plugins for flytekit", + 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/flytekit/plugins/flytekit-airflow/tests/__init__.py b/flytekit/plugins/flytekit-airflow/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-airflow/tests/test_agent.py b/flytekit/plugins/flytekit-airflow/tests/test_agent.py new file mode 100644 index 0000000000..dc4d167b10 --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/tests/test_agent.py @@ -0,0 +1,98 @@ +from datetime import datetime, timedelta, timezone + +import jsonpickle +import pytest +from airflow.operators.python import PythonOperator +from airflow.sensors.bash import BashSensor +from airflow.sensors.time_sensor import TimeSensor +from flyteidl.admin.agent_pb2 import DeleteTaskResponse +from flyteidl.core.execution_pb2 import TaskExecution +from flytekitplugins.airflow import AirflowObj +from flytekitplugins.airflow.agent import AirflowAgent, ResourceMetadata + +from flytekit import workflow +from flytekit.interfaces.cli_identifiers import Identifier +from flytekit.models import interface as interface_models +from flytekit.models import literals, task +from flytekit.models.core.identifier import ResourceType +from flytekit.models.task import TaskTemplate + + +def py_func(): + print("airflow python sensor") + return True + + +@workflow +def wf(): + sensor = TimeSensor( + task_id="fire_immediately", target_time=(datetime.now(tz=timezone.utc) + timedelta(seconds=1)).time() + ) + t3 = BashSensor(task_id="Sensor_succeeds", bash_command="exit 0") + foo = PythonOperator(task_id="foo", python_callable=py_func) + sensor >> t3 >> foo + + +def test_airflow_workflow(): + wf() + + +def test_resource_metadata(): + task_cfg = AirflowObj( + module="airflow.operators.bash", + name="BashOperator", + parameters={"task_id": "id", "bash_command": "echo 'hello world'"}, + ) + trigger_cfg = AirflowObj(module="airflow.trigger.file", name="FileTrigger", parameters={"filepath": "file.txt"}) + meta = ResourceMetadata( + airflow_operator=task_cfg, + airflow_trigger=trigger_cfg, + airflow_trigger_callback="execute_complete", + job_id="123", + ) + assert meta.airflow_operator == task_cfg + assert meta.airflow_trigger == trigger_cfg + assert meta.airflow_trigger_callback == "execute_complete" + assert meta.job_id == "123" + + +@pytest.mark.asyncio +async def test_airflow_agent(): + cfg = AirflowObj( + module="airflow.operators.bash", + name="BashOperator", + parameters={"task_id": "id", "bash_command": "echo 'hello world'"}, + ) + task_id = Identifier( + resource_type=ResourceType.TASK, project="project", domain="domain", name="airflow_Task", 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", + ) + + interfaces = interface_models.TypedInterface(inputs={}, outputs={}) + + dummy_template = TaskTemplate( + id=task_id, + metadata=task_metadata, + interface=interfaces, + type="airflow", + custom={"task_config_pkl": jsonpickle.encode(cfg)}, + ) + + agent = AirflowAgent() + res = await agent.create("/tmp", dummy_template, None) + metadata = res.resource_meta + res = await agent.get(metadata) + assert res.resource.phase == TaskExecution.SUCCEEDED + assert res.resource.message == "" + res = await agent.delete(metadata) + assert res == DeleteTaskResponse() diff --git a/flytekit/plugins/flytekit-airflow/tests/test_task.py b/flytekit/plugins/flytekit-airflow/tests/test_task.py new file mode 100644 index 0000000000..81399e63a9 --- /dev/null +++ b/flytekit/plugins/flytekit-airflow/tests/test_task.py @@ -0,0 +1,122 @@ +import jsonpickle +from airflow.operators.bash import BashOperator +from airflow.providers.apache.beam.operators.beam import BeamRunJavaPipelineOperator, BeamRunPythonPipelineOperator +from airflow.providers.google.cloud.operators.dataproc import DataprocCreateClusterOperator +from airflow.sensors.bash import BashSensor +from airflow.sensors.time_sensor import TimeSensor +from airflow.utils.context import Context +from flytekitplugins.airflow.task import ( + AirflowContainerTask, + AirflowObj, + AirflowTask, + _flyte_operator, + _is_deferrable, + airflow_task_resolver, +) +from mock import mock + +from flytekit import FlyteContextManager +from flytekit.configuration import ImageConfig, SerializationSettings +from flytekit.core import context_manager + + +def test_xcom_push(): + ctx = FlyteContextManager.current_context() + ctx.user_space_params._attrs = {} + + execution_state = ctx.execution_state.with_params( + user_space_params=ctx.user_space_params.new_builder() + .add_attr("GET_ORIGINAL_TASK", True) + .add_attr("XCOM_DATA", {}) + .build() + ) + + with FlyteContextManager.with_context(ctx.with_execution_state(execution_state)) as child_ctx: + print(child_ctx.user_space_params.get_original_task) + op = BashSensor(task_id="Sensor_succeeds", bash_command="exit 0") + op.xcom_push(Context(), "key", "value") + assert child_ctx.user_space_params.xcom_data[1] == "key" + assert child_ctx.user_space_params.xcom_data[2] == "value" + + +def test_is_deferrable(): + assert _is_deferrable(BeamRunJavaPipelineOperator) is False + assert _is_deferrable(BashSensor) is False + assert _is_deferrable(DataprocCreateClusterOperator) is True + + +def test_airflow_task(): + cfg = AirflowObj( + module="airflow.operators.bash", + name="BashOperator", + parameters={"task_id": "id", "bash_command": "echo 'hello world'"}, + ) + t = AirflowTask(name="test_bash_operator", task_config=cfg) + serialization_settings = SerializationSettings( + project="proj", + domain="dom", + version="123", + image_config=ImageConfig.auto(), + env={}, + ) + t.get_custom(serialization_settings)["task_config_pkl"] = jsonpickle.encode(cfg) + t.execute() + + +def test_airflow_container_task(): + cfg = AirflowObj( + module="airflow.providers.apache.beam.operators.beam", + name="BeamRunJavaPipelineOperator", + parameters={"task_id": "id", "job_class": "org.apache.beam.examples.WordCount"}, + ) + t = AirflowContainerTask(name="test_dataflow_operator", task_config=cfg) + serialization_settings = SerializationSettings( + project="proj", + domain="dom", + version="123", + image_config=ImageConfig.auto(), + env={}, + ) + assert t.task_resolver.name() == "AirflowTaskResolver" + assert t.task_resolver.loader_args(serialization_settings, t) == [ + "task-module", + "flytekitplugins.airflow.task", + "task-name", + "AirflowContainerTask", + "task-config", + '{"py/object": "flytekitplugins.airflow.task.AirflowObj", "module": ' + '"airflow.providers.apache.beam.operators.beam", "name": ' + '"BeamRunJavaPipelineOperator", "parameters": {"task_id": "id", "job_class": ' + '"org.apache.beam.examples.WordCount"}}', + ] + assert isinstance( + airflow_task_resolver.load_task(t.task_resolver.loader_args(serialization_settings, t)), AirflowContainerTask + ) + + +@mock.patch("flytekitplugins.airflow.task.AirflowContainerTask") +@mock.patch("flytekitplugins.airflow.task.AirflowTask") +def test_flyte_operator(airflow_task, airflow_container_task): + ctx = context_manager.FlyteContext.current_context() + with context_manager.FlyteContextManager.with_context(ctx.new_builder()): + params = FlyteContextManager.current_context().user_space_params + params.builder().add_attr("GET_ORIGINAL_TASK", False).add_attr("XCOM_DATA", {}).build() + _flyte_operator(BashOperator, task_id="BashOperator") + airflow_task.assert_called_once() + _flyte_operator(BeamRunJavaPipelineOperator, task_id="BeamRunJavaPipelineOperator") + airflow_container_task.assert_called_once() + + airflow_task.reset_mock() + airflow_container_task.reset_mock() + + _flyte_operator(TimeSensor, task_id="TimeSensor") + airflow_task.assert_called_once() + + _flyte_operator(BeamRunPythonPipelineOperator, task_id="BeamRunPythonPipelineOperator") + airflow_container_task.assert_called_once() + + airflow_task.reset_mock() + airflow_container_task.reset_mock() + + _flyte_operator(DataprocCreateClusterOperator, task_id="DataprocCreateClusterOperator") + airflow_task.assert_called_once() diff --git a/flytekit/plugins/flytekit-async-fsspec/README.md b/flytekit/plugins/flytekit-async-fsspec/README.md new file mode 100644 index 0000000000..7dfc1c6de9 --- /dev/null +++ b/flytekit/plugins/flytekit-async-fsspec/README.md @@ -0,0 +1,14 @@ +# Flytekit Async fsspec Plugin + +The Flyte async fsspec plugin is a powerful addition to the Flyte ecosystem designed to optimize the performance of object transmission. This plugin focuses on overriding key methods of the file systems in fsspec to introduce efficiency improvements, resulting in accelerated data transfers between Flyte workflows and object storage. + +Currently, the async fsspec plugin improves the following file systems: +1. s3fs + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-async-fsspec +``` + +Once installed, the plugin will automatically override the original file system and register optimized ones, seamlessly integrating with your Flyte workflows. diff --git a/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/__init__.py b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/__init__.py new file mode 100644 index 0000000000..3cc0de14e7 --- /dev/null +++ b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/__init__.py @@ -0,0 +1,16 @@ +""" +.. currentmodule:: flytekitplugins.async_fsspec + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + AsyncS3FileSystem +""" +import fsspec + +from .s3fs.s3fs import AsyncS3FileSystem + +fsspec.register_implementation("s3", AsyncS3FileSystem) diff --git a/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/__init__.py b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/constants.py b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/constants.py new file mode 100644 index 0000000000..e5058a03c1 --- /dev/null +++ b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/constants.py @@ -0,0 +1,6 @@ +DEFAULT_UPLOAD_CHUNK_SIZE = 50 * 2**20 # 50MB +DEFAULT_CONCURRENT_UPLOAD = 4 +SINGLE_OBJECT_UPLOAD_LIMIT = 5 * 2**30 # 5GB +DEFAULT_DOWNLOAD_CHUNK_SIZE = 50 * 2**20 # 50MB +DEFAULT_CONCURRENT_DOWNLOAD = 4 +DEFAULT_DOWNLOAD_BODY_READ_SIZE = 2**16 # from s3fs diff --git a/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/s3fs.py b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/s3fs.py new file mode 100644 index 0000000000..c44d09a23c --- /dev/null +++ b/flytekit/plugins/flytekit-async-fsspec/flytekitplugins/async_fsspec/s3fs/s3fs.py @@ -0,0 +1,240 @@ +import asyncio +import mimetypes +import os + +from fsspec.callbacks import _DEFAULT_CALLBACK +from s3fs import S3FileSystem +from s3fs.core import S3_RETRYABLE_ERRORS, version_id_kw + +from .constants import ( + DEFAULT_CONCURRENT_DOWNLOAD, + DEFAULT_CONCURRENT_UPLOAD, + DEFAULT_DOWNLOAD_BODY_READ_SIZE, + DEFAULT_DOWNLOAD_CHUNK_SIZE, + DEFAULT_UPLOAD_CHUNK_SIZE, + SINGLE_OBJECT_UPLOAD_LIMIT, +) + + +class AsyncS3FileSystem(S3FileSystem): + def __init__(self, **s3kwargs): + super().__init__(**s3kwargs) + + async def _put_file( + self, + lpath, + rpath, + callback=_DEFAULT_CALLBACK, + chunksize=DEFAULT_UPLOAD_CHUNK_SIZE, + concurrent_upload=DEFAULT_CONCURRENT_UPLOAD, + **kwargs, + ): + """ + Put a file from lpath to rpath. + Args: + lpath (str): The local path of the file to be uploaded. + rpath (str): The remote path which the file should be uploaded to. + callback (function, optional): The callback function. + chunksize (int, optional): Upload chunksize. Defaults to 50 * 2**20 (50MB). + concurrent_upload (int, optional): The number of concurrent upload when using multipart upload. Defaults to 4. + """ + bucket, key, _ = self.split_path(rpath) + if os.path.isdir(lpath): + if key: + # don't make remote "directory" + return + else: + await self._mkdir(lpath) + size = os.path.getsize(lpath) + callback.set_size(size) + + if "ContentType" not in kwargs: + content_type, _ = mimetypes.guess_type(lpath) + if content_type is not None: + kwargs["ContentType"] = content_type + + with open(lpath, "rb") as f0: + if size < min(SINGLE_OBJECT_UPLOAD_LIMIT, 2 * chunksize): + chunk = f0.read() + await self._call_s3("put_object", Bucket=bucket, Key=key, Body=chunk, **kwargs) + callback.relative_update(size) + else: + mpu = await self._call_s3("create_multipart_upload", Bucket=bucket, Key=key, **kwargs) + + # async function to upload a single chunk + async def upload_chunk(chunk, part_number): + result = await self._call_s3( + "upload_part", + Bucket=bucket, + PartNumber=part_number, + UploadId=mpu["UploadId"], + Body=chunk, + Key=key, + ) + callback.relative_update(len(chunk)) + return {"PartNumber": part_number, "ETag": result["ETag"]} + + tasks = set() + part_number = 1 + parts = [] + read_end = False + while True: + while len(tasks) < concurrent_upload: + chunk = f0.read(chunksize) + if not chunk: + read_end = True + break + tasks.add(asyncio.create_task(upload_chunk(chunk, part_number))) + part_number += 1 + if read_end: + break + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + parts.extend(map(lambda x: x.result(), done)) + tasks = pending + + parts.extend(await asyncio.gather(*tasks)) + parts.sort(key=lambda part: part["PartNumber"]) + await self._call_s3( + "complete_multipart_upload", + Bucket=bucket, + Key=key, + UploadId=mpu["UploadId"], + MultipartUpload={"Parts": parts}, + ) + while rpath: + self.invalidate_cache(rpath) + rpath = self._parent(rpath) + + async def _get_file( + self, + rpath, + lpath, + callback=_DEFAULT_CALLBACK, + version_id=None, + chunksize=DEFAULT_DOWNLOAD_CHUNK_SIZE, + concurrent_download=DEFAULT_CONCURRENT_DOWNLOAD, + ): + """ + Get a file from rpath to lpath. + Args: + rpath (str): The remote path of the file to be downloaded. + lpath (str): The local path which the file should be downloaded to. + callback (function, optional): The callback function. + chunksize (int, optional): Download chunksize. Defaults to 50 * 2**20 (50MB). + version_id (str, optional): The version id of the file. Defaults to None. + """ + if os.path.isdir(lpath): + return + + # get the file size + file_info = await self._info(path=rpath, version_id=version_id) + file_size = file_info["size"] + + bucket, key, vers = self.split_path(rpath) + + # the async function to get a range of the remote file + async def _open_file(start_byte: int, end_byte: int = None): + kw = self.req_kw.copy() + if end_byte: + kw["Range"] = f"bytes={start_byte}-{end_byte}" + else: + kw["Range"] = f"bytes={start_byte}" + resp = await self._call_s3( + "get_object", + Bucket=bucket, + Key=key, + **version_id_kw(version_id or vers), + **kw, + ) + return resp["Body"], resp.get("ContentLength", None) + + # Refer to s3fs's implementation + async def handle_read_error(body, failed_reads, restart_byte, end_byte=None): + if failed_reads >= self.retries: + raise + try: + body.close() + except Exception: + pass + + await asyncio.sleep(min(1.7**failed_reads * 0.1, 15)) + body, _ = await _open_file(restart_byte, end_byte) + return body + + # According to s3fs documentation, some file systems might not be able to measure the file’s size, + # in which case, the returned dict will include 'size': None. When we cannot get the file size + # in advance, we keep using the original implementation of s3fs. + if file_size is None: + # From s3fs + body, content_length = await _open_file(start_byte=0) + callback.set_size(content_length) + + failed_reads = 0 + bytes_read = 0 + + try: + with open(lpath, "wb") as f0: + while True: + try: + chunk = await body.read(DEFAULT_DOWNLOAD_BODY_READ_SIZE) + except S3_RETRYABLE_ERRORS: + failed_reads += 1 + body = await handle_read_error(body, failed_reads, bytes_read) + continue + + if not chunk: + break + + f0.write(chunk) + bytes_read += len(chunk) + callback.relative_update(len(chunk)) + finally: + try: + body.close() + except Exception: + pass + else: + callback.set_size(file_size) + with open(lpath, "wb") as f0: + # async function to download a single chunk + async def download_chunk(chunk_index: int): + start_byte = chunk_index * chunksize + end_byte = min(start_byte + chunksize, file_size) - 1 + body, _ = await _open_file(start_byte, end_byte) + failed_reads = 0 + bytes_read = 0 + try: + while True: + try: + chunk = await body.read(DEFAULT_DOWNLOAD_BODY_READ_SIZE) + except S3_RETRYABLE_ERRORS: + failed_reads += 1 + body = await handle_read_error(body, failed_reads, start_byte + bytes_read, end_byte) + continue + + if not chunk: + break + + f0.seek(start_byte + bytes_read) + f0.write(chunk) + bytes_read += len(chunk) + callback.relative_update(len(chunk)) + finally: + try: + body.close() + except Exception: + pass + + chunk_count = file_size // chunksize + if file_size % chunksize > 0: + chunk_count += 1 + + tasks = set() + current_chunk = 0 + while current_chunk < chunk_count: + while current_chunk < chunk_count and len(tasks) < concurrent_download: + tasks.add(asyncio.create_task(download_chunk(current_chunk))) + current_chunk += 1 + _, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + tasks = pending + await asyncio.gather(*tasks) diff --git a/flytekit/plugins/flytekit-async-fsspec/setup.py b/flytekit/plugins/flytekit-async-fsspec/setup.py new file mode 100644 index 0000000000..141f2b6081 --- /dev/null +++ b/flytekit/plugins/flytekit-async-fsspec/setup.py @@ -0,0 +1,37 @@ +from setuptools import setup + +PLUGIN_NAME = "async_fsspec" + +microlib_name = "flytekitplugins-async-fsspec" + +plugin_requires = ["flytekit"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package holds the data persistence plugins for flytekit", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}", f"flytekitplugins.{PLUGIN_NAME}.s3fs"], + 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/flytekit/plugins/flytekit-async-fsspec/tests/__init__.py b/flytekit/plugins/flytekit-async-fsspec/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-async-fsspec/tests/test_s3fs.py b/flytekit/plugins/flytekit-async-fsspec/tests/test_s3fs.py new file mode 100644 index 0000000000..97025ee72d --- /dev/null +++ b/flytekit/plugins/flytekit-async-fsspec/tests/test_s3fs.py @@ -0,0 +1,201 @@ +import os +from unittest import mock +from unittest.mock import MagicMock, mock_open + +import pytest +from flytekitplugins.async_fsspec import AsyncS3FileSystem +from flytekitplugins.async_fsspec.s3fs.constants import DEFAULT_DOWNLOAD_CHUNK_SIZE, DEFAULT_UPLOAD_CHUNK_SIZE + + +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._parent") +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem.invalidate_cache") +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._call_s3") +@mock.patch("mimetypes.guess_type") +@mock.patch("os.path.getsize") +@pytest.mark.asyncio +async def test_put_file_single_object_upload( + mock_getsize, mock_guess_type, mock_call_s3, mock_invalidate_cache, mock_parent +): + mock_bucket = "mock-bucket" + mock_file_name = "mock_file_name" + mock_file_size = 32 * 2**20 # 32MB + mock_getsize.return_value = mock_file_size + mock_guess_type.return_value = (None, None) + mock_parent.return_value = None + mock_body = os.urandom(mock_file_size) + m = mock_open(read_data=mock_body) + + with mock.patch("builtins.open", m): + asyncs3fs = AsyncS3FileSystem() + await asyncs3fs._put_file(lpath=f"/{mock_file_name}", rpath=f"s3://{mock_bucket}/{mock_file_name}") + + mock_call_s3.assert_called_once_with("put_object", Bucket=mock_bucket, Key=mock_file_name, Body=mock_body) + + +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._parent") +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem.invalidate_cache") +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._call_s3") +@mock.patch("mimetypes.guess_type") +@mock.patch("os.path.getsize") +@pytest.mark.asyncio +async def test_put_file_multipart_upload( + mock_getsize, mock_guess_type, mock_call_s3, mock_invalidate_cache, mock_parent +): + mock_bucket = "mock-bucket" + mock_file_name = "mock_file_name" + mock_upload_id = "mock_upload_id" + mock_ETag = "mock_ETag" + mock_file_size = 256 * 2**20 # 256MB + mock_getsize.return_value = mock_file_size + mock_guess_type.return_value = (None, None) + + def call_s3_side_effect(*args, **kwargs): + if args[0] == "create_multipart_upload": + return {"UploadId": mock_upload_id} + elif args[0] == "upload_part": + part_number = kwargs["PartNumber"] + return {"ETag": f"{mock_ETag}{part_number}"} + elif args[0] == "complete_multipart_upload": + return None + + mock_call_s3.side_effect = call_s3_side_effect + + mock_parent.return_value = None + + mock_body = os.urandom(mock_file_size) + m = mock_open(read_data=mock_body) + + with mock.patch("builtins.open", m): + asyncs3fs = AsyncS3FileSystem() + await asyncs3fs._put_file(lpath=f"/{mock_file_name}", rpath=f"s3://{mock_bucket}/{mock_file_name}") + + mock_chunk_count = mock_file_size // DEFAULT_UPLOAD_CHUNK_SIZE + if mock_file_size % DEFAULT_UPLOAD_CHUNK_SIZE > 0: + mock_chunk_count += 1 + put_object_calls = [] + for i in range(mock_chunk_count): + part_number = i + 1 + start_byte = i * DEFAULT_UPLOAD_CHUNK_SIZE + end_byte = min(start_byte + DEFAULT_UPLOAD_CHUNK_SIZE, mock_file_size) + body = mock_body[start_byte:end_byte] + put_object_calls.append( + mock.call( + "upload_part", + Bucket=mock_bucket, + Key=mock_file_name, + PartNumber=part_number, + UploadId=mock_upload_id, + Body=body, + ), + ) + + mock_call_s3.assert_has_calls( + put_object_calls + + [ + mock.call("create_multipart_upload", Bucket=mock_bucket, Key=mock_file_name), + mock.call( + "complete_multipart_upload", + Bucket=mock_bucket, + Key=mock_file_name, + UploadId=mock_upload_id, + MultipartUpload={ + "Parts": [{"PartNumber": i, "ETag": f"{mock_ETag}{i}"} for i in range(1, mock_chunk_count + 1)] + }, + ), + ], + any_order=True, + ) + assert mock_call_s3.call_count == 2 + mock_chunk_count + + +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._call_s3") +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._info") +@mock.patch("os.path.isdir") +@pytest.mark.asyncio +async def test_get_file_file_size_is_none(mock_isdir, mock_info, mock_call_s3): + mock_bucket = "mock-bucket" + mock_file_name = "mock_file_name" + mock_file_size = 32 * 2**20 # 32MB + mock_isdir.return_value = False + mock_info.return_value = {"size": None} + + file_been_read = 0 + + async def read_side_effect(*args, **kwargs): + read_size = args[0] + nonlocal file_been_read + real_read_size = min(read_size, mock_file_size - file_been_read) + if real_read_size == 0: + return None + file_been_read += real_read_size + return os.urandom(real_read_size) + + mock_chunk = MagicMock() + mock_chunk.read.side_effect = read_side_effect + mock_call_s3.return_value = {"Body": mock_chunk, "ContentLength": mock_file_size} + + m = mock_open() + + with mock.patch("builtins.open", m): + asyncs3fs = AsyncS3FileSystem() + await asyncs3fs._get_file(lpath=f"/{mock_file_name}", rpath=f"s3://{mock_bucket}/{mock_file_name}") + + assert file_been_read == mock_file_size + mock_call_s3.assert_called_once_with("get_object", Bucket=mock_bucket, Key=mock_file_name, Range="bytes=0") + + +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._call_s3") +@mock.patch("flytekitplugins.async_fsspec.AsyncS3FileSystem._info") +@mock.patch("os.path.isdir") +@pytest.mark.asyncio +async def test_get_file_file_size_is_not_none(mock_isdir, mock_info, mock_call_s3): + mock_bucket = "mock-bucket" + mock_file_name = "mock_file_name" + mock_file_size = 256 * 2**20 # 256MB + mock_isdir.return_value = False + mock_info.return_value = {"size": mock_file_size} + + file_been_read = 0 + + def call_s3_side_effect(*args, **kwargs): + start_byte, end_byte = kwargs["Range"][6:].split("-") + start_byte, end_byte = int(start_byte), int(end_byte) + chunk_size = end_byte - start_byte + 1 + chunk_been_read = 0 + + async def read_side_effect(*args, **kwargs): + nonlocal chunk_been_read + nonlocal file_been_read + read_size = args[0] + real_read_size = min(read_size, chunk_size - chunk_been_read) + if real_read_size == 0: + return None + chunk_been_read += real_read_size + file_been_read += real_read_size + return os.urandom(real_read_size) + + mock_chunk = MagicMock() + mock_chunk.read.side_effect = read_side_effect + return {"Body": mock_chunk, "ContentLength": chunk_size} + + mock_call_s3.side_effect = call_s3_side_effect + + m = mock_open() + with mock.patch("builtins.open", m): + asyncs3fs = AsyncS3FileSystem() + await asyncs3fs._get_file(lpath=f"/{mock_file_name}", rpath=f"s3://{mock_bucket}/{mock_file_name}") + + assert file_been_read == mock_file_size + + mock_chunk_count = mock_file_size // DEFAULT_DOWNLOAD_CHUNK_SIZE + if mock_file_size % DEFAULT_DOWNLOAD_CHUNK_SIZE > 0: + mock_chunk_count += 1 + get_object_calls = [] + for i in range(mock_chunk_count): + start_byte = i * DEFAULT_DOWNLOAD_CHUNK_SIZE + end_byte = min(start_byte + DEFAULT_DOWNLOAD_CHUNK_SIZE, mock_file_size) - 1 + get_object_calls.append( + mock.call("get_object", Bucket=mock_bucket, Key=mock_file_name, Range=f"bytes={start_byte}-{end_byte}") + ) + mock_call_s3.assert_has_calls(get_object_calls) + assert mock_call_s3.call_count == len(get_object_calls) diff --git a/flytekit/plugins/flytekit-aws-athena/README.md b/flytekit/plugins/flytekit-aws-athena/README.md new file mode 100644 index 0000000000..99b0a5ebc9 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-athena/README.md @@ -0,0 +1,11 @@ +# Flytekit AWS Athena Plugin + +Flyte backend can be connected with Athena. Once enabled, it allows you to query AWS Athena service (Presto + ANSI SQL Support) and retrieve typed schema (optionally). + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-athena +``` + +An [example](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/aws/athena/athena.html#sphx-glr-auto-integrations-aws-athena-athena-py) can be found in the documentation. diff --git a/flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/__init__.py b/flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/__init__.py new file mode 100644 index 0000000000..4bb51fc53c --- /dev/null +++ b/flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/__init__.py @@ -0,0 +1,14 @@ +""" +.. currentmodule:: flytekitplugins.athena + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + AthenaConfig + AthenaTask +""" + +from .task import AthenaConfig, AthenaTask diff --git a/flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/task.py b/flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/task.py new file mode 100644 index 0000000000..1ae47339b3 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-athena/flytekitplugins/athena/task.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type + +from google.protobuf.json_format import MessageToDict + +from flytekit.configuration import SerializationSettings +from flytekit.extend import SQLTask +from flytekit.models.presto import PrestoQuery +from flytekit.types.schema import FlyteSchema + + +@dataclass +class AthenaConfig(object): + """ + AthenaConfig should be used to configure a Athena Task. + """ + + # The database to query against + database: Optional[str] = None + # The optional workgroup to separate query execution. + workgroup: Optional[str] = None + # The catalog to set for the given Presto query + catalog: Optional[str] = None + + +class AthenaTask(SQLTask[AthenaConfig]): + """ + This is the simplest form of a Athena Task, that can be used even for tasks that do not produce any output. + """ + + # This task is executed using the presto handler in the backend. + _TASK_TYPE = "presto" + + def __init__( + self, + name: str, + query_template: str, + task_config: Optional[AthenaConfig] = None, + inputs: Optional[Dict[str, Type]] = None, + output_schema_type: Optional[Type[FlyteSchema]] = None, + **kwargs, + ): + """ + To be used to query Athena databases. + + :param name: Name of this task, should be unique in the project + :param query_template: The actual query to run. We use Flyte's Golang templating format for Query templating. + Refer to the templating documentation + :param task_config: AthenaConfig object + :param inputs: Name and type of inputs specified as an ordered dictionary + :param output_schema_type: If some data is produced by this query, then you can specify the output schema type + :param kwargs: All other args required by Parent type - SQLTask + """ + outputs = None + if output_schema_type is not None: + outputs = { + "results": output_schema_type, + } + if task_config is None: + task_config = AthenaConfig() + super().__init__( + name=name, + task_config=task_config, + query_template=query_template, + inputs=inputs, + outputs=outputs, + task_type=self._TASK_TYPE, + **kwargs, + ) + self._output_schema_type = output_schema_type + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + # This task is executed using the presto handler in the backend. + job = PrestoQuery( + statement=self.query_template, + schema=self.task_config.database, + routing_group=self.task_config.workgroup, + catalog=self.task_config.catalog, + ) + return MessageToDict(job.to_flyte_idl()) diff --git a/flytekit/plugins/flytekit-aws-athena/setup.py b/flytekit/plugins/flytekit-aws-athena/setup.py new file mode 100644 index 0000000000..5bbf0581d2 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-athena/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup + +PLUGIN_NAME = "athena" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package holds the Athena plugins for flytekit", + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/flytekit/plugins/flytekit-aws-athena/tests/__init__.py b/flytekit/plugins/flytekit-aws-athena/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-aws-athena/tests/test_athena.py b/flytekit/plugins/flytekit-aws-athena/tests/test_athena.py new file mode 100644 index 0000000000..4489e59e7d --- /dev/null +++ b/flytekit/plugins/flytekit-aws-athena/tests/test_athena.py @@ -0,0 +1,74 @@ +from collections import OrderedDict + +import pytest +from flytekitplugins.athena import AthenaConfig, AthenaTask + +from flytekit import kwtypes, workflow +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.extend import get_serializable +from flytekit.types.schema import FlyteSchema + + +def test_serialization(): + athena_task = AthenaTask( + name="flytekit.demo.athena_task.query", + inputs=kwtypes(ds=str), + task_config=AthenaConfig(database="mnist", catalog="my_catalog", workgroup="my_wg"), + query_template=""" + insert overwrite directory '{{ .rawOutputDataPrefix }}' stored as parquet + select * + from blah + where ds = '{{ .Inputs.ds }}' + """, + # the schema literal's backend uri will be equal to the value of .raw_output_data + output_schema_type=FlyteSchema, + ) + + @workflow + def my_wf(ds: str) -> FlyteSchema: + return athena_task(ds=ds) + + default_img = Image(name="default", fqn="test", tag="tag") + serialization_settings = SerializationSettings( + project="proj", + domain="dom", + version="123", + image_config=ImageConfig(default_image=default_img, images=[default_img]), + env={}, + ) + task_spec = get_serializable(OrderedDict(), serialization_settings, athena_task) + assert "{{ .rawOutputDataPrefix" in task_spec.template.custom["statement"] + assert "insert overwrite directory" in task_spec.template.custom["statement"] + assert "mnist" == task_spec.template.custom["schema"] + assert "my_catalog" == task_spec.template.custom["catalog"] + assert "my_wg" == task_spec.template.custom["routingGroup"] + assert len(task_spec.template.interface.inputs) == 1 + assert len(task_spec.template.interface.outputs) == 1 + + admin_workflow_spec = get_serializable(OrderedDict(), serialization_settings, my_wf) + assert admin_workflow_spec.template.interface.outputs["o0"].type.schema is not None + assert admin_workflow_spec.template.outputs[0].var == "o0" + assert admin_workflow_spec.template.outputs[0].binding.promise.node_id == "n0" + assert admin_workflow_spec.template.outputs[0].binding.promise.var == "results" + + +def test_local_exec(): + athena_task = AthenaTask( + name="flytekit.demo.athena_task.query2", + inputs=kwtypes(ds=str), + query_template=""" + insert overwrite directory '{{ .rawOutputDataPrefix }}' stored as parquet + select * + from blah + where ds = '{{ .Inputs.ds }}' + """, + # the schema literal's backend uri will be equal to the value of .raw_output_data + output_schema_type=FlyteSchema, + ) + + assert len(athena_task.interface.inputs) == 1 + assert len(athena_task.interface.outputs) == 1 + + # will not run locally + with pytest.raises(Exception): + athena_task() diff --git a/flytekit/plugins/flytekit-aws-batch/README.md b/flytekit/plugins/flytekit-aws-batch/README.md new file mode 100644 index 0000000000..b56663237b --- /dev/null +++ b/flytekit/plugins/flytekit-aws-batch/README.md @@ -0,0 +1,9 @@ +# Flytekit AWS Batch Plugin + +Flyte backend can be connected with AWS batch. Once enabled, it allows you to run flyte task on AWS batch service + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-awsbatch +``` diff --git a/flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/__init__.py b/flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/__init__.py new file mode 100644 index 0000000000..59ce4f5af1 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/__init__.py @@ -0,0 +1,13 @@ +""" +.. currentmodule:: flytekitplugins.awsbatch + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + AWSBatchConfig +""" + +from .task import AWSBatchConfig diff --git a/flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/task.py b/flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/task.py new file mode 100644 index 0000000000..e0326f112b --- /dev/null +++ b/flytekit/plugins/flytekit-aws-batch/flytekitplugins/awsbatch/task.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +from dataclasses_json import DataClassJsonMixin +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Struct + +from flytekit import PythonFunctionTask +from flytekit.configuration import SerializationSettings +from flytekit.extend import TaskPlugins + + +@dataclass +class AWSBatchConfig(DataClassJsonMixin): + """ + Use this to configure SubmitJobInput for a AWS batch job. Task's marked with this will automatically execute + natively onto AWS batch service. + Refer to AWS SubmitJobInput for more detail: https://docs.aws.amazon.com/sdk-for-go/api/service/batch/#SubmitJobInput + """ + + parameters: Optional[Dict[str, str]] = None + schedulingPriority: Optional[int] = None + platformCapabilities: str = "EC2" + propagateTags: Optional[bool] = None + tags: Optional[Dict[str, str]] = None + + def to_dict(self): + s = Struct() + s.update(super().to_dict()) + return json_format.MessageToDict(s) + + +class AWSBatchFunctionTask(PythonFunctionTask): + """ + Actual Plugin that transforms the local python code for execution within AWS batch job + """ + + _AWS_BATCH_TASK_TYPE = "aws-batch" + + def __init__(self, task_config: AWSBatchConfig, task_function: Callable, **kwargs): + if task_config is None: + task_config = AWSBatchConfig() + super(AWSBatchFunctionTask, self).__init__( + task_config=task_config, task_type=self._AWS_BATCH_TASK_TYPE, task_function=task_function, **kwargs + ) + self._task_config = task_config + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + # task_config will be used to create SubmitJobInput in propeller except platformCapabilities. + return self._task_config.to_dict() + + def get_config(self, settings: SerializationSettings) -> Dict[str, str]: + # Parameters in taskTemplate config will be used to create aws job definition. + # More detail about job definition: https://docs.aws.amazon.com/batch/latest/userguide/job_definition_parameters.html + return {**super().get_config(settings), "platformCapabilities": self._task_config.platformCapabilities} + + def get_command(self, settings: SerializationSettings) -> List[str]: + container_args = [ + "pyflyte-execute", + "--inputs", + "{{.input}}", + "--output-prefix", + # As of FlytePropeller v0.16.28, aws array batch plugin support to run single job. + # This task will call aws batch plugin to execute the task on aws batch service. + # For single job, FlytePropeller will always read the output from this directory (outputPrefix/0) + # More detail, see https://github.com/flyteorg/flyteplugins/blob/0dd93c23ed2edeca65d58e89b0edb613f88120e0/go/tasks/plugins/array/catalog.go#L501. + "{{.outputPrefix}}/0", + "--raw-output-data-prefix", + "{{.rawOutputDataPrefix}}", + "--resolver", + self.task_resolver.location, + "--", + *self.task_resolver.loader_args(settings, self), + ] + + return container_args + + +# Inject the AWS batch plugin into flytekits dynamic plugin loading system +TaskPlugins.register_pythontask_plugin(AWSBatchConfig, AWSBatchFunctionTask) diff --git a/flytekit/plugins/flytekit-aws-batch/setup.py b/flytekit/plugins/flytekit-aws-batch/setup.py new file mode 100644 index 0000000000..db75ce18b9 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-batch/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup + +PLUGIN_NAME = "awsbatch" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package holds the AWS Batch plugins for flytekit", + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/flytekit/plugins/flytekit-aws-batch/tests/__init__.py b/flytekit/plugins/flytekit-aws-batch/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-aws-batch/tests/test_aws_batch.py b/flytekit/plugins/flytekit-aws-batch/tests/test_aws_batch.py new file mode 100644 index 0000000000..73eada2b09 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-batch/tests/test_aws_batch.py @@ -0,0 +1,49 @@ +from flytekitplugins.awsbatch import AWSBatchConfig + +from flytekit import PythonFunctionTask, task +from flytekit.configuration import Image, ImageConfig, SerializationSettings + +config = AWSBatchConfig( + parameters={"codec": "mp4"}, + platformCapabilities="EC2", + propagateTags=True, + tags={"hello": "world"}, +) + + +def test_aws_batch_task(): + @task(task_config=config) + def t1(a: int) -> str: + inc = a + 2 + return str(inc) + + assert t1.task_config is not None + assert t1.task_config == config + assert t1.task_type == "aws-batch" + assert isinstance(t1, PythonFunctionTask) + + 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]), + ) + assert t1.get_custom(settings) == config.to_dict() + assert t1.get_command(settings) == [ + "pyflyte-execute", + "--inputs", + "{{.input}}", + "--output-prefix", + "{{.outputPrefix}}/0", + "--raw-output-data-prefix", + "{{.rawOutputDataPrefix}}", + "--resolver", + "flytekit.core.python_auto_container.default_task_resolver", + "--", + "task-module", + "tests.test_aws_batch", + "task-name", + "t1", + ] diff --git a/flytekit/plugins/flytekit-aws-sagemaker/README.md b/flytekit/plugins/flytekit-aws-sagemaker/README.md new file mode 100644 index 0000000000..0974da52c5 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/README.md @@ -0,0 +1,13 @@ +# Flytekit AWS Sagemaker Plugin + +Amazon SageMaker provides several built-in machine learning algorithms that you can use for a variety of problem types. Flyte Sagemaker plugin intends to greatly simplify using Sagemaker for training. We have tried to distill the API into a meaningful subset that makes it easier for users to adopt and run with Sagemaker. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-awssagemaker +``` + +To install Sagemaker in the Flyte deployment's backend, go through the [prerequisites](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/aws/sagemaker_training/index.html#prerequisites). + +[Built-in sagemaker](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/aws/sagemaker_training/sagemaker_builtin_algo_training.html#sphx-glr-auto-integrations-aws-sagemaker-training-sagemaker-builtin-algo-training-py) and [custom sagemaker](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/aws/sagemaker_training/sagemaker_custom_training.html#sphx-glr-auto-integrations-aws-sagemaker-training-sagemaker-custom-training-py) training models can be found in the documentation. diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/__init__.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/__init__.py new file mode 100644 index 0000000000..6dce099dfb --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/__init__.py @@ -0,0 +1,85 @@ +""" +.. currentmodule:: flytekitplugins.awssagemaker + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + AlgorithmName + AlgorithmSpecification + CategoricalParameterRange + ContinuousParameterRange + DISTRIBUTED_TRAINING_CONTEXT_KEY + DistributedProtocol + DistributedTrainingContext + HPOJob + HyperparameterScalingType + HyperparameterTuningJobConfig + HyperparameterTuningObjective + HyperparameterTuningObjectiveType + HyperparameterTuningStrategy + InputContentType + InputMode + IntegerParameterRange + ParameterRangeOneOf + SagemakerCustomTrainingTask + SagemakerHPOTask + SagemakerTrainingJobConfig + TrainingJobEarlyStoppingType + TrainingJobResourceConfig +""" + +__all__ = [ + "AlgorithmName", + "AlgorithmSpecification", + "CategoricalParameterRange", + "ContinuousParameterRange", + "DISTRIBUTED_TRAINING_CONTEXT_KEY", + "DistributedProtocol", + "DistributedTrainingContext", + "HPOJob", + "HyperparameterScalingType", + "HyperparameterTuningJobConfig", + "HyperparameterTuningObjective", + "HyperparameterTuningObjectiveType", + "HyperparameterTuningStrategy", + "InputContentType", + "InputMode", + "IntegerParameterRange", + "ParameterRangeOneOf", + "SagemakerBuiltinAlgorithmsTask", + "SagemakerCustomTrainingTask", + "SagemakerHPOTask", + "SagemakerTrainingJobConfig", + "TrainingJobEarlyStoppingType", + "TrainingJobResourceConfig", +] + +from flytekitplugins.awssagemaker.models.hpo_job import ( + HyperparameterTuningJobConfig, + HyperparameterTuningObjective, + HyperparameterTuningObjectiveType, + HyperparameterTuningStrategy, + TrainingJobEarlyStoppingType, +) +from flytekitplugins.awssagemaker.models.parameter_ranges import ( + CategoricalParameterRange, + ContinuousParameterRange, + HyperparameterScalingType, + IntegerParameterRange, + ParameterRangeOneOf, +) +from flytekitplugins.awssagemaker.models.training_job import ( + AlgorithmName, + AlgorithmSpecification, + DistributedProtocol, + InputContentType, + InputMode, + TrainingJobResourceConfig, +) + +from .distributed_training import DISTRIBUTED_TRAINING_CONTEXT_KEY, DistributedTrainingContext +from .hpo import HPOJob, SagemakerHPOTask +from .training import SagemakerBuiltinAlgorithmsTask, SagemakerCustomTrainingTask, SagemakerTrainingJobConfig diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/distributed_training.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/distributed_training.py new file mode 100644 index 0000000000..2b69bd430d --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/distributed_training.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import os +import typing +from dataclasses import dataclass + +import retry + +SM_RESOURCE_CONFIG_FILE = "/opt/ml/input/config/resourceconfig.json" +SM_ENV_VAR_CURRENT_HOST = "SM_CURRENT_HOST" +SM_ENV_VAR_HOSTS = "SM_HOSTS" +SM_ENV_VAR_NETWORK_INTERFACE_NAME = "SM_NETWORK_INTERFACE_NAME" + + +def setup_envars_for_testing(): + """ + This method is useful in simulating the env variables that sagemaker will set on the execution environment + """ + os.environ[SM_ENV_VAR_CURRENT_HOST] = "host" + os.environ[SM_ENV_VAR_HOSTS] = '["host1","host2"]' + os.environ[SM_ENV_VAR_NETWORK_INTERFACE_NAME] = "nw" + + +@dataclass +class DistributedTrainingContext(object): + current_host: str + hosts: typing.List[str] + network_interface_name: str + + @classmethod + @retry.retry(exceptions=KeyError, delay=1, tries=10, backoff=1) + def from_env(cls) -> DistributedTrainingContext: + """ + SageMaker suggests "Hostname information might not be immediately available to the processing container. + We recommend adding a retry policy on hostname resolution operations as nodes become available in the cluster." + https://docs.aws.amazon.com/sagemaker/latest/dg/build-your-own-processing-container.html#byoc-config + This is why we have an automatic retry policy + """ + curr_host = os.environ.get(SM_ENV_VAR_CURRENT_HOST) + raw_hosts = os.environ.get(SM_ENV_VAR_HOSTS) + nw_iface = os.environ.get(SM_ENV_VAR_NETWORK_INTERFACE_NAME) + if not (curr_host and raw_hosts and nw_iface): + raise KeyError("Unable to locate Sagemaker Environment variables!") + hosts = json.loads(raw_hosts) + return DistributedTrainingContext(curr_host, hosts, nw_iface) + + @classmethod + @retry.retry(exceptions=FileNotFoundError, delay=1, tries=10, backoff=1) + def from_sagemaker_context_file(cls) -> DistributedTrainingContext: + with open(SM_RESOURCE_CONFIG_FILE, "r") as rc_file: + d = json.load(rc_file) + curr_host = d["current_host"] + hosts = d["hosts"] + nw_iface = d["network_interface_name"] + + if not (curr_host and hosts and nw_iface): + raise KeyError + + return DistributedTrainingContext(curr_host, hosts, nw_iface) + + @classmethod + def local_execute(cls) -> DistributedTrainingContext: + """ + Creates a dummy local execution context for distributed execution. + TODO revisit if this is a good idea + """ + return DistributedTrainingContext(hosts=["localhost"], current_host="localhost", network_interface_name="dummy") + + +DISTRIBUTED_TRAINING_CONTEXT_KEY = "DISTRIBUTED_TRAINING_CONTEXT" +""" +Use this key to retrieve the distributed training context of type :py:class:`DistributedTrainingContext`. +Usage: + +.. code-block:: python + + ctx = flytekit.current_context().distributed_training_context + # OR + ctx = flytekit.current_context().get(sagemaker.DISTRIBUTED_TRAINING_CONTEXT_KEY) + +""" diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/hpo.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/hpo.py new file mode 100644 index 0000000000..1229b96195 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/hpo.py @@ -0,0 +1,168 @@ +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Type, Union + +from flyteidl.plugins.sagemaker import hyperparameter_tuning_job_pb2 as _pb2_hpo_job +from flyteidl.plugins.sagemaker import parameter_ranges_pb2 as _pb2_params +from flytekitplugins.awssagemaker.training import SagemakerBuiltinAlgorithmsTask, SagemakerCustomTrainingTask +from google.protobuf import json_format +from google.protobuf.json_format import MessageToDict + +from flytekit import FlyteContext +from flytekit.configuration import SerializationSettings +from flytekit.extend import DictTransformer, PythonTask, TypeEngine, TypeTransformer +from flytekit.models.literals import Literal +from flytekit.models.types import LiteralType, SimpleType + +from .models import hpo_job as _hpo_job_model +from .models import parameter_ranges as _params +from .models import training_job as _training_job_model + + +@dataclass +class HPOJob(object): + """ + HPOJob Configuration should be used to configure the HPO Job. + + Args: + max_number_of_training_jobs: maximum number of jobs to run for a training round + max_parallel_training_jobs: limits the concurrency of the training jobs + tunable_params: [optional] should be a list of parameters for which we want to provide the tuning ranges + """ + + max_number_of_training_jobs: int + max_parallel_training_jobs: int + # TODO. we could make the tunable params a tuple of name and type of range? + tunable_params: Optional[List[str]] = None + + +# TODO Not done yet, but once we clean this up, the client interface should be simplified. The interface should +# Just take a list of Union of different types of Parameter Ranges. Lets see how simplify that +class SagemakerHPOTask(PythonTask[HPOJob]): + _SAGEMAKER_HYPERPARAMETER_TUNING_JOB_TASK = "sagemaker_hyperparameter_tuning_job_task" + + def __init__( + self, + name: str, + task_config: HPOJob, + training_task: Union[SagemakerCustomTrainingTask, SagemakerBuiltinAlgorithmsTask], + **kwargs, + ): + if training_task is None or not ( + isinstance(training_task, SagemakerCustomTrainingTask) + or isinstance(training_task, SagemakerBuiltinAlgorithmsTask) + ): + raise ValueError( + "Training Task of type SagemakerCustomTrainingTask/SagemakerBuiltinAlgorithmsTask is required to work" + " with Sagemaker HPO" + ) + + self._task_config = task_config + self._training_task = training_task + + extra_inputs = {"hyperparameter_tuning_job_config": _hpo_job_model.HyperparameterTuningJobConfig} + + if task_config.tunable_params: + extra_inputs.update({param: _params.ParameterRangeOneOf for param in task_config.tunable_params}) + + iface = training_task.python_interface + updated_iface = iface.with_inputs(extra_inputs) + super().__init__( + task_type=self._SAGEMAKER_HYPERPARAMETER_TUNING_JOB_TASK, + name=name, + interface=updated_iface, + task_config=task_config, + **kwargs, + ) + + def execute(self, **kwargs) -> Any: + raise NotImplementedError("Sagemaker HPO Task cannot be executed locally, to execute locally mock it!") + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + training_job = _training_job_model.TrainingJob( + algorithm_specification=self._training_task.task_config.algorithm_specification, + training_job_resource_config=self._training_task.task_config.training_job_resource_config, + ) + return MessageToDict( + _hpo_job_model.HyperparameterTuningJob( + max_number_of_training_jobs=self.task_config.max_number_of_training_jobs, + max_parallel_training_jobs=self.task_config.max_parallel_training_jobs, + training_job=training_job, + ).to_flyte_idl() + ) + + +# %% +# HPO Task allows ParameterRangeOneOf and HyperparameterTuningJobConfig as inputs. In flytekit this is possible +# to allow these two types to be registered as valid input / output types and provide a custom transformer +# We will create custom transformers for them as follows and provide them once a user loads HPO task + + +class HPOTuningJobConfigTransformer(TypeTransformer[_hpo_job_model.HyperparameterTuningJobConfig]): + """ + Transformer to make ``HyperparameterTuningJobConfig`` an accepted value, for which a transformer is registered + """ + + def __init__(self): + super().__init__("sagemaker-hpojobconfig-transformer", _hpo_job_model.HyperparameterTuningJobConfig) + + def get_literal_type(self, t: Type[_hpo_job_model.HyperparameterTuningJobConfig]) -> LiteralType: + return LiteralType(simple=SimpleType.STRUCT, metadata=None) + + def to_literal( + self, + ctx: FlyteContext, + python_val: _hpo_job_model.HyperparameterTuningJobConfig, + python_type: Type[_hpo_job_model.HyperparameterTuningJobConfig], + expected: LiteralType, + ) -> Literal: + d = MessageToDict(python_val.to_flyte_idl()) + return DictTransformer.dict_to_generic_literal(d) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[_hpo_job_model.HyperparameterTuningJobConfig] + ) -> _hpo_job_model.HyperparameterTuningJobConfig: + if lv and lv.scalar and lv.scalar.generic is not None: + d = json.loads(json_format.MessageToJson(lv.scalar.generic)) + o = _pb2_hpo_job.HyperparameterTuningJobConfig() + o = json_format.ParseDict(d, o) + return _hpo_job_model.HyperparameterTuningJobConfig.from_flyte_idl(o) + return None + + +class ParameterRangesTransformer(TypeTransformer[_params.ParameterRangeOneOf]): + """ + Transformer to make ``ParameterRange`` an accepted value, for which a transformer is registered + """ + + def __init__(self): + super().__init__("sagemaker-paramrange-transformer", _params.ParameterRangeOneOf) + + def get_literal_type(self, t: Type[_params.ParameterRangeOneOf]) -> LiteralType: + return LiteralType(simple=SimpleType.STRUCT, metadata=None) + + def to_literal( + self, + ctx: FlyteContext, + python_val: _params.ParameterRangeOneOf, + python_type: Type[_hpo_job_model.HyperparameterTuningJobConfig], + expected: LiteralType, + ) -> Literal: + d = MessageToDict(python_val.to_flyte_idl()) + return DictTransformer.dict_to_generic_literal(d) + + def to_python_value( + self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[_params.ParameterRangeOneOf] + ) -> _params.ParameterRangeOneOf: + if lv and lv.scalar and lv.scalar.generic is not None: + d = json.loads(json_format.MessageToJson(lv.scalar.generic)) + o = _pb2_params.ParameterRangeOneOf() + o = json_format.ParseDict(d, o) + return _params.ParameterRangeOneOf.from_flyte_idl(o) + return None + + +# %% +# Register the types +TypeEngine.register(HPOTuningJobConfigTransformer()) +TypeEngine.register(ParameterRangesTransformer()) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/__init__.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/hpo_job.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/hpo_job.py new file mode 100644 index 0000000000..6d6b17189f --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/hpo_job.py @@ -0,0 +1,181 @@ +from flyteidl.plugins.sagemaker import hyperparameter_tuning_job_pb2 as _pb2_hpo_job + +from flytekit.models import common as _common + +from . import training_job as _training_job + + +class HyperparameterTuningObjectiveType(object): + MINIMIZE = _pb2_hpo_job.HyperparameterTuningObjectiveType.MINIMIZE + MAXIMIZE = _pb2_hpo_job.HyperparameterTuningObjectiveType.MAXIMIZE + + +class HyperparameterTuningObjective(_common.FlyteIdlEntity): + """ + HyperparameterTuningObjective is a data structure that contains the target metric and the + objective of the hyperparameter tuning. + + https://docs.aws.amazon.com/sagemaker/latest/dg/automatic-model-tuning-define-metrics.html + """ + + def __init__( + self, + objective_type: int, + metric_name: str, + ): + self._objective_type = objective_type + self._metric_name = metric_name + + @property + def objective_type(self) -> int: + """ + Enum value of HyperparameterTuningObjectiveType. objective_type determines the direction of the tuning of + the Hyperparameter Tuning Job with respect to the specified metric. + :rtype: int + """ + return self._objective_type + + @property + def metric_name(self) -> str: + """ + The target metric name, which is the user-defined name of the metric specified in the + training job's algorithm specification + :rtype: str + """ + return self._metric_name + + def to_flyte_idl(self) -> _pb2_hpo_job.HyperparameterTuningObjective: + return _pb2_hpo_job.HyperparameterTuningObjective( + objective_type=self.objective_type, + metric_name=self._metric_name, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _pb2_hpo_job.HyperparameterTuningObjective): + return cls( + objective_type=pb2_object.objective_type, + metric_name=pb2_object.metric_name, + ) + + +class HyperparameterTuningStrategy: + BAYESIAN = _pb2_hpo_job.HyperparameterTuningStrategy.BAYESIAN + RANDOM = _pb2_hpo_job.HyperparameterTuningStrategy.RANDOM + + +class TrainingJobEarlyStoppingType: + OFF = _pb2_hpo_job.TrainingJobEarlyStoppingType.OFF + AUTO = _pb2_hpo_job.TrainingJobEarlyStoppingType.AUTO + + +class HyperparameterTuningJobConfig(_common.FlyteIdlEntity): + """ + The specification of the hyperparameter tuning process + https://docs.aws.amazon.com/sagemaker/latest/dg/automatic-model-tuning-ex-tuning-job.html#automatic-model-tuning-ex-low-tuning-config + """ + + def __init__( + self, + tuning_strategy: int, + tuning_objective: HyperparameterTuningObjective, + training_job_early_stopping_type: TrainingJobEarlyStoppingType, + ): + self._tuning_strategy = tuning_strategy + self._tuning_objective = tuning_objective + self._training_job_early_stopping_type = training_job_early_stopping_type + + @property + def tuning_strategy(self) -> int: + """ + Enum value of HyperparameterTuningStrategy. Setting the strategy used when searching in the hyperparameter space + :rtype: int + """ + return self._tuning_strategy + + @property + def tuning_objective(self) -> HyperparameterTuningObjective: + """ + The target metric and the objective of the hyperparameter tuning. + :rtype: HyperparameterTuningObjective + """ + return self._tuning_objective + + @property + def training_job_early_stopping_type(self) -> int: + """ + Enum value of TrainingJobEarlyStoppingType. When the training jobs launched by the hyperparameter tuning job + are not improving significantly, a hyperparameter tuning job can be stopping early. This attribute determines + how the early stopping is to be done. + Note that there's only a subset of built-in algorithms that supports early stopping. + see: https://docs.aws.amazon.com/sagemaker/latest/dg/automatic-model-tuning-early-stopping.html + :rtype: int + """ + return self._training_job_early_stopping_type + + def to_flyte_idl(self) -> _pb2_hpo_job.HyperparameterTuningJobConfig: + return _pb2_hpo_job.HyperparameterTuningJobConfig( + tuning_strategy=self._tuning_strategy, + tuning_objective=self._tuning_objective.to_flyte_idl(), + training_job_early_stopping_type=self._training_job_early_stopping_type, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _pb2_hpo_job.HyperparameterTuningJobConfig): + return cls( + tuning_strategy=pb2_object.tuning_strategy, + tuning_objective=HyperparameterTuningObjective.from_flyte_idl(pb2_object.tuning_objective), + training_job_early_stopping_type=pb2_object.training_job_early_stopping_type, + ) + + +class HyperparameterTuningJob(_common.FlyteIdlEntity): + def __init__( + self, + max_number_of_training_jobs: int, + max_parallel_training_jobs: int, + training_job: _training_job.TrainingJob, + ): + self._max_number_of_training_jobs = max_number_of_training_jobs + self._max_parallel_training_jobs = max_parallel_training_jobs + self._training_job = training_job + + @property + def max_number_of_training_jobs(self) -> int: + """ + The maximum number of training jobs that a hyperparameter tuning job can launch. + https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ResourceLimits.html + :rtype: int + """ + return self._max_number_of_training_jobs + + @property + def max_parallel_training_jobs(self) -> int: + """ + The maximum number of concurrent training job that an hpo job can launch + https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_ResourceLimits.html + :rtype: int + """ + return self._max_parallel_training_jobs + + @property + def training_job(self) -> _training_job.TrainingJob: + """ + The reference to the underlying training job that the hyperparameter tuning job will launch during the process + :rtype: _training_job.TrainingJob + """ + return self._training_job + + def to_flyte_idl(self) -> _pb2_hpo_job.HyperparameterTuningJob: + return _pb2_hpo_job.HyperparameterTuningJob( + max_number_of_training_jobs=self._max_number_of_training_jobs, + max_parallel_training_jobs=self._max_parallel_training_jobs, + training_job=self._training_job.to_flyte_idl(), # SDK task has already serialized it + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _pb2_hpo_job.HyperparameterTuningJob): + return cls( + max_number_of_training_jobs=pb2_object.max_number_of_training_jobs, + max_parallel_training_jobs=pb2_object.max_parallel_training_jobs, + training_job=_training_job.TrainingJob.from_flyte_idl(pb2_object.training_job), + ) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/parameter_ranges.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/parameter_ranges.py new file mode 100644 index 0000000000..738f1820a2 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/parameter_ranges.py @@ -0,0 +1,315 @@ +from typing import Dict, List, Optional, Union + +from flyteidl.plugins.sagemaker import parameter_ranges_pb2 as _idl_parameter_ranges + +from flytekit.exceptions import user +from flytekit.models import common as _common + + +class HyperparameterScalingType(object): + AUTO = _idl_parameter_ranges.HyperparameterScalingType.AUTO + LINEAR = _idl_parameter_ranges.HyperparameterScalingType.LINEAR + LOGARITHMIC = _idl_parameter_ranges.HyperparameterScalingType.LOGARITHMIC + REVERSELOGARITHMIC = _idl_parameter_ranges.HyperparameterScalingType.REVERSELOGARITHMIC + + +class ContinuousParameterRange(_common.FlyteIdlEntity): + def __init__( + self, + max_value: float, + min_value: float, + scaling_type: int, + ): + """ + + :param float max_value: + :param float min_value: + :param int scaling_type: + """ + self._max_value = max_value + self._min_value = min_value + self._scaling_type = scaling_type + + @property + def max_value(self) -> float: + """ + + :rtype: float + """ + return self._max_value + + @property + def min_value(self) -> float: + """ + + :rtype: float + """ + return self._min_value + + @property + def scaling_type(self) -> int: + """ + enum value from HyperparameterScalingType + :rtype: int + """ + return self._scaling_type + + def to_flyte_idl(self) -> _idl_parameter_ranges.ContinuousParameterRange: + """ + :rtype: _idl_parameter_ranges.ContinuousParameterRange + """ + + return _idl_parameter_ranges.ContinuousParameterRange( + max_value=self._max_value, + min_value=self._min_value, + scaling_type=self.scaling_type, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _idl_parameter_ranges.ContinuousParameterRange): + """ + + :param pb2_object: + :rtype: ContinuousParameterRange + """ + return cls( + max_value=pb2_object.max_value, + min_value=pb2_object.min_value, + scaling_type=pb2_object.scaling_type, + ) + + +class IntegerParameterRange(_common.FlyteIdlEntity): + def __init__( + self, + max_value: int, + min_value: int, + scaling_type: int, + ): + """ + :param int max_value: + :param int min_value: + :param int scaling_type: + """ + self._max_value = max_value + self._min_value = min_value + self._scaling_type = scaling_type + + @property + def max_value(self) -> int: + """ + :rtype: int + """ + return self._max_value + + @property + def min_value(self) -> int: + """ + + :rtype: int + """ + return self._min_value + + @property + def scaling_type(self) -> int: + """ + enum value from HyperparameterScalingType + :rtype: int + """ + return self._scaling_type + + def to_flyte_idl(self) -> _idl_parameter_ranges.IntegerParameterRange: + """ + :rtype: _idl_parameter_ranges.IntegerParameterRange + """ + return _idl_parameter_ranges.IntegerParameterRange( + max_value=self._max_value, + min_value=self._min_value, + scaling_type=self.scaling_type, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _idl_parameter_ranges.IntegerParameterRange): + """ + + :param pb2_object: + :rtype: IntegerParameterRange + """ + return cls( + max_value=pb2_object.max_value, + min_value=pb2_object.min_value, + scaling_type=pb2_object.scaling_type, + ) + + +class CategoricalParameterRange(_common.FlyteIdlEntity): + def __init__( + self, + values: List[str], + ): + """ + + :param List[str] values: list of strings representing categorical values + """ + self._values = values + + @property + def values(self) -> List[str]: + """ + :rtype: List[str] + """ + return self._values + + def to_flyte_idl(self) -> _idl_parameter_ranges.CategoricalParameterRange: + """ + :rtype: _idl_parameter_ranges.CategoricalParameterRange + """ + return _idl_parameter_ranges.CategoricalParameterRange(values=self._values) + + @classmethod + def from_flyte_idl(cls, pb2_object: _idl_parameter_ranges.CategoricalParameterRange): + """ + + :param pb2_object: + :rtype: CategoricalParameterRange + """ + return cls(values=[v for v in pb2_object.values]) + + +class ParameterRanges(_common.FlyteIdlEntity): + def __init__( + self, + parameter_range_map: Dict[str, _common.FlyteIdlEntity], + ): + self._parameter_range_map = parameter_range_map + + def to_flyte_idl(self) -> _idl_parameter_ranges.ParameterRanges: + """ + + :rtype: _idl_parameter_ranges.ParameterRanges + """ + converted = {} + for k, v in self._parameter_range_map.items(): + if isinstance(v, IntegerParameterRange): + converted[k] = _idl_parameter_ranges.ParameterRangeOneOf(integer_parameter_range=v.to_flyte_idl()) + elif isinstance(v, ContinuousParameterRange): + converted[k] = _idl_parameter_ranges.ParameterRangeOneOf(continuous_parameter_range=v.to_flyte_idl()) + elif isinstance(v, CategoricalParameterRange): + converted[k] = _idl_parameter_ranges.ParameterRangeOneOf(categorical_parameter_range=v.to_flyte_idl()) + else: + raise user.FlyteTypeException( + received_type=type(v), + expected_type=type( + Union[IntegerParameterRange, ContinuousParameterRange, CategoricalParameterRange] + ), + ) + + return _idl_parameter_ranges.ParameterRanges( + parameter_range_map=converted, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _idl_parameter_ranges.ParameterRanges): + """ + + :param pb2_object: + :rtype: ParameterRanges + """ + converted = {} + for k, v in pb2_object.parameter_range_map.items(): + if v.HasField("continuous_parameter_range"): + converted[k] = ContinuousParameterRange.from_flyte_idl(v.continuous_parameter_range) + elif v.HasField("integer_parameter_range"): + converted[k] = IntegerParameterRange.from_flyte_idl(v.integer_parameter_range) + else: + converted[k] = CategoricalParameterRange.from_flyte_idl(v.categorical_parameter_range) + + return cls( + parameter_range_map=converted, + ) + + +class ParameterRangeOneOf(_common.FlyteIdlEntity): + def __init__(self, param: Union[IntegerParameterRange, ContinuousParameterRange, CategoricalParameterRange]): + """ + Initializes a new ParameterRangeOneOf. + + :param Union[IntegerParameterRange, ContinuousParameterRange, CategoricalParameterRange] param: One of the + supported parameter ranges. + """ + self._integer_parameter_range = param if isinstance(param, IntegerParameterRange) else None + self._continuous_parameter_range = param if isinstance(param, ContinuousParameterRange) else None + self._categorical_parameter_range = param if isinstance(param, CategoricalParameterRange) else None + + @property + def integer_parameter_range(self) -> Optional[IntegerParameterRange]: + """ + Retrieves the integer parameter range if one is set. None otherwise. + :rtype: Optional[IntegerParameterRange] + """ + if self._integer_parameter_range: + return self._integer_parameter_range + + return None + + @property + def continuous_parameter_range(self) -> Optional[ContinuousParameterRange]: + """ + Retrieves the continuous parameter range if one is set. None otherwise. + :rtype: Optional[ContinuousParameterRange] + """ + if self._continuous_parameter_range: + return self._continuous_parameter_range + + return None + + @property + def categorical_parameter_range(self) -> Optional[CategoricalParameterRange]: + """ + Retrieves the categorical parameter range if one is set. None otherwise. + :rtype: Optional[CategoricalParameterRange] + """ + if self._categorical_parameter_range: + return self._categorical_parameter_range + + return None + + def to_flyte_idl(self) -> _idl_parameter_ranges.ParameterRangeOneOf: + return _idl_parameter_ranges.ParameterRangeOneOf( + integer_parameter_range=self.integer_parameter_range.to_flyte_idl() + if self.integer_parameter_range + else None, + continuous_parameter_range=self.continuous_parameter_range.to_flyte_idl() + if self.continuous_parameter_range + else None, + categorical_parameter_range=self.categorical_parameter_range.to_flyte_idl() + if self.categorical_parameter_range + else None, + ) + + @classmethod + def from_flyte_idl( + cls, + pb_object: Union[ + _idl_parameter_ranges.ParameterRangeOneOf, + _idl_parameter_ranges.IntegerParameterRange, + _idl_parameter_ranges.ContinuousParameterRange, + _idl_parameter_ranges.CategoricalParameterRange, + ], + ): + param = None + if isinstance(pb_object, _idl_parameter_ranges.ParameterRangeOneOf): + if pb_object.HasField("continuous_parameter_range"): + param = ContinuousParameterRange.from_flyte_idl(pb_object.continuous_parameter_range) + elif pb_object.HasField("integer_parameter_range"): + param = IntegerParameterRange.from_flyte_idl(pb_object.integer_parameter_range) + elif pb_object.HasField("categorical_parameter_range"): + param = CategoricalParameterRange.from_flyte_idl(pb_object.categorical_parameter_range) + elif isinstance(pb_object, _idl_parameter_ranges.IntegerParameterRange): + param = IntegerParameterRange.from_flyte_idl(pb_object) + elif isinstance(pb_object, _idl_parameter_ranges.ContinuousParameterRange): + param = ContinuousParameterRange.from_flyte_idl(pb_object) + elif isinstance(pb_object, _idl_parameter_ranges.CategoricalParameterRange): + param = CategoricalParameterRange.from_flyte_idl(pb_object) + + return cls(param=param) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/training_job.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/training_job.py new file mode 100644 index 0000000000..238aa27fa4 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/models/training_job.py @@ -0,0 +1,326 @@ +from typing import List + +from flyteidl.plugins.sagemaker import training_job_pb2 as _training_job_pb2 + +from flytekit.models import common as _common + + +class DistributedProtocol(object): + """ + The distribution framework is used for determining which underlying distributed training mechanism to use. + This is only required for use cases where the user wants to train its custom training job in a distributed manner + """ + + UNSPECIFIED = _training_job_pb2.DistributedProtocol.UNSPECIFIED + MPI = _training_job_pb2.DistributedProtocol.MPI + + +class TrainingJobResourceConfig(_common.FlyteIdlEntity): + """ + TrainingJobResourceConfig is a pass-through, specifying the instance type to use for the training job, the + number of instances to launch, and the size of the ML storage volume the user wants to provision + Refer to SageMaker official doc for more details: https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_CreateTrainingJob.html + """ + + def __init__( + self, + instance_type: str, + volume_size_in_gb: int, + instance_count: int = 1, + distributed_protocol: int = DistributedProtocol.UNSPECIFIED, + ): + self._instance_count = instance_count + self._instance_type = instance_type + self._volume_size_in_gb = volume_size_in_gb + self._distributed_protocol = distributed_protocol + + @property + def instance_count(self) -> int: + """ + The number of ML compute instances to use. For distributed training, provide a value greater than 1. + :rtype: int + """ + return self._instance_count + + @property + def instance_type(self) -> str: + """ + The ML compute instance type. + :rtype: str + """ + return self._instance_type + + @property + def volume_size_in_gb(self) -> int: + """ + The size of the ML storage volume that you want to provision to store the data and intermediate artifacts, etc. + :rtype: int + """ + return self._volume_size_in_gb + + @property + def distributed_protocol(self) -> int: + """ + The distribution framework is used to determine through which mechanism the distributed training is done. + enum value from DistributionFramework. + :rtype: int + """ + return self._distributed_protocol + + def to_flyte_idl(self) -> _training_job_pb2.TrainingJobResourceConfig: + """ + + :rtype: _training_job_pb2.TrainingJobResourceConfig + """ + return _training_job_pb2.TrainingJobResourceConfig( + instance_count=self.instance_count, + instance_type=self.instance_type, + volume_size_in_gb=self.volume_size_in_gb, + distributed_protocol=self.distributed_protocol, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _training_job_pb2.TrainingJobResourceConfig): + """ + + :param pb2_object: + :rtype: TrainingJobResourceConfig + """ + return cls( + instance_count=pb2_object.instance_count, + instance_type=pb2_object.instance_type, + volume_size_in_gb=pb2_object.volume_size_in_gb, + distributed_protocol=pb2_object.distributed_protocol, + ) + + +class MetricDefinition(_common.FlyteIdlEntity): + def __init__( + self, + name: str, + regex: str, + ): + self._name = name + self._regex = regex + + @property + def name(self) -> str: + """ + The user-defined name of the metric + :rtype: str + """ + return self._name + + @property + def regex(self) -> str: + """ + SageMaker hyperparameter tuning using this regex to parses your algorithm’s stdout and stderr + streams to find the algorithm metrics on which the users want to track + :rtype: str + """ + return self._regex + + def to_flyte_idl(self) -> _training_job_pb2.MetricDefinition: + """ + + :rtype: _training_job_pb2.MetricDefinition + """ + return _training_job_pb2.MetricDefinition( + name=self.name, + regex=self.regex, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _training_job_pb2.MetricDefinition): + """ + + :param pb2_object: _training_job_pb2.MetricDefinition + :rtype: MetricDefinition + """ + return cls( + name=pb2_object.name, + regex=pb2_object.regex, + ) + + +# TODO Convert to Enum +class InputMode(object): + """ + When using FILE input mode, different SageMaker built-in algorithms require different file types of input data + See https://docs.aws.amazon.com/sagemaker/latest/dg/cdf-training.html + https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-algo-docker-registry-paths.html + """ + + PIPE = _training_job_pb2.InputMode.PIPE + FILE = _training_job_pb2.InputMode.FILE + + +# TODO Convert to enum +class AlgorithmName(object): + """ + The algorithm name is used for deciding which pre-built image to point to. + This is only required for use cases where SageMaker's built-in algorithm mode is used. + While we currently only support a subset of the algorithms, more will be added to the list. + See: https://docs.aws.amazon.com/sagemaker/latest/dg/algos.html + """ + + CUSTOM = _training_job_pb2.AlgorithmName.CUSTOM + XGBOOST = _training_job_pb2.AlgorithmName.XGBOOST + + +# TODO convert to enum +class InputContentType(object): + """ + Specifies the type of content for input data. Different SageMaker built-in algorithms require different content types of input data + See https://docs.aws.amazon.com/sagemaker/latest/dg/cdf-training.html + https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-algo-docker-registry-paths.html + """ + + TEXT_CSV = _training_job_pb2.InputContentType.TEXT_CSV + + +class AlgorithmSpecification(_common.FlyteIdlEntity): + """ + Specifies the training algorithm to be used in the training job + This object is mostly a pass-through, with a couple of exceptions include: (1) in Flyte, users don't need to specify + TrainingImage; either use the built-in algorithm mode by using Flytekit's Simple Training Job and specifying an algorithm + name and an algorithm version or (2) when users want to supply custom algorithms they should set algorithm_name field to + CUSTOM. In this case, the value of the algorithm_version field has no effect + For pass-through use cases: refer to this AWS official document for more details + https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_AlgorithmSpecification.html + """ + + def __init__( + self, + algorithm_name: int = AlgorithmName.CUSTOM, + algorithm_version: str = "", + input_mode: int = InputMode.FILE, + metric_definitions: List[MetricDefinition] = None, + input_content_type: int = InputContentType.TEXT_CSV, + ): + self._input_mode = input_mode + self._input_content_type = input_content_type + self._algorithm_name = algorithm_name + self._algorithm_version = algorithm_version + self._metric_definitions = metric_definitions or [] + + @property + def input_mode(self) -> int: + """ + enum value from InputMode. The input mode can be either PIPE or FILE + :rtype: int + """ + return self._input_mode + + @property + def input_content_type(self) -> int: + """ + enum value from InputContentType. The content type of the input data + See https://docs.aws.amazon.com/sagemaker/latest/dg/cdf-training.html + https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-algo-docker-registry-paths.html + :rtype: int + """ + return self._input_content_type + + @property + def algorithm_name(self) -> int: + """ + The algorithm name is used for deciding which pre-built image to point to. + enum value from AlgorithmName. + :rtype: int + """ + return self._algorithm_name + + @property + def algorithm_version(self) -> str: + """ + version of the algorithm (if using built-in algorithm mode). + :rtype: str + """ + return self._algorithm_version + + @property + def metric_definitions(self) -> List[MetricDefinition]: + """ + A list of metric definitions for SageMaker to evaluate/track on the progress of the training job + See this: https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_AlgorithmSpecification.html + + Note that, when you use one of the Amazon SageMaker built-in algorithms, you cannot define custom metrics. + If you are doing hyperparameter tuning, built-in algorithms automatically send metrics to hyperparameter tuning. + When using hyperparameter tuning, you do need to choose one of the metrics that the built-in algorithm emits as + the objective metric for the tuning job. + See this: https://docs.aws.amazon.com/sagemaker/latest/dg/automatic-model-tuning-define-metrics.html + :rtype: List[MetricDefinition] + """ + return self._metric_definitions + + def to_flyte_idl(self) -> _training_job_pb2.AlgorithmSpecification: + return _training_job_pb2.AlgorithmSpecification( + input_mode=self.input_mode, + algorithm_name=self.algorithm_name, + algorithm_version=self.algorithm_version, + metric_definitions=[m.to_flyte_idl() for m in self.metric_definitions], + input_content_type=self.input_content_type, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _training_job_pb2.AlgorithmSpecification): + return cls( + input_mode=pb2_object.input_mode, + algorithm_name=pb2_object.algorithm_name, + algorithm_version=pb2_object.algorithm_version, + metric_definitions=[MetricDefinition.from_flyte_idl(m) for m in pb2_object.metric_definitions], + input_content_type=pb2_object.input_content_type, + ) + + +class TrainingJob(_common.FlyteIdlEntity): + def __init__( + self, + algorithm_specification: AlgorithmSpecification, + training_job_resource_config: TrainingJobResourceConfig, + ): + self._algorithm_specification = algorithm_specification + self._training_job_resource_config = training_job_resource_config + + @property + def algorithm_specification(self) -> AlgorithmSpecification: + """ + Contains the information related to the algorithm to use in the training job + :rtype: AlgorithmSpecification + """ + return self._algorithm_specification + + @property + def training_job_resource_config(self) -> TrainingJobResourceConfig: + """ + Specifies the information around the instances that will be used to run the training job. + :rtype: TrainingJobResourceConfig + """ + return self._training_job_resource_config + + def to_flyte_idl(self) -> _training_job_pb2.TrainingJob: + """ + :rtype: _training_job_pb2.TrainingJob + """ + + return _training_job_pb2.TrainingJob( + algorithm_specification=self.algorithm_specification.to_flyte_idl() + if self.algorithm_specification + else None, + training_job_resource_config=self.training_job_resource_config.to_flyte_idl() + if self.training_job_resource_config + else None, + ) + + @classmethod + def from_flyte_idl(cls, pb2_object: _training_job_pb2.TrainingJob): + """ + + :param pb2_object: + :rtype: TrainingJob + """ + return cls( + algorithm_specification=pb2_object.algorithm_specification, + training_job_resource_config=pb2_object.training_job_resource_config, + ) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/training.py b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/training.py new file mode 100644 index 0000000000..7f456d19a0 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/flytekitplugins/awssagemaker/training.py @@ -0,0 +1,192 @@ +import typing +from dataclasses import dataclass +from typing import Any, Callable, Dict + +from flytekitplugins.awssagemaker.distributed_training import DistributedTrainingContext +from google.protobuf.json_format import MessageToDict +from typing_extensions import Annotated + +import flytekit +from flytekit import ExecutionParameters, FlyteContextManager, PythonFunctionTask, kwtypes +from flytekit.configuration import SerializationSettings +from flytekit.extend import ExecutionState, IgnoreOutputs, Interface, PythonTask, TaskPlugins +from flytekit.loggers import logger +from flytekit.types.directory.types import FlyteDirectory +from flytekit.types.file import FileExt, FlyteFile + +from .models import training_job as _training_job_models + + +@dataclass +class SagemakerTrainingJobConfig(object): + """ + Configuration for Running Training Jobs on Sagemaker. This config can be used to run either the built-in algorithms + or custom algorithms. + + Args: + training_job_resource_config: Configuration for Resources to use during the training + algorithm_specification: Specification of the algorithm to use + should_persist_output: This method will be invoked and will decide if the generated model should be persisted + as the output. ``NOTE: Useful only for distributed training`` + ``default: single node training - always persist output`` + ``default: distributed training - always persist output on node with rank-0`` + """ + + training_job_resource_config: _training_job_models.TrainingJobResourceConfig + algorithm_specification: _training_job_models.AlgorithmSpecification + # The default output-persisting predicate. + # With this predicate, only the copy running on the first host in the list of hosts would persist its output + should_persist_output: typing.Callable[[DistributedTrainingContext], bool] = lambda dctx: ( + dctx.current_host == dctx.hosts[0] + ) + + +class SagemakerBuiltinAlgorithmsTask(PythonTask[SagemakerTrainingJobConfig]): + """ + Implements an interface that allows execution of a SagemakerBuiltinAlgorithms. + Refer to `Sagemaker Builtin Algorithms`_ for details. + """ + + _SAGEMAKER_TRAINING_JOB_TASK = "sagemaker_training_job_task" + + OUTPUT_TYPE = Annotated[str, FileExt("tar.gz")] + + def __init__( + self, + name: str, + task_config: SagemakerTrainingJobConfig, + **kwargs, + ): + """ + Args: + name: name of this specific task. This should be unique within the project. A good strategy is to prefix + with the module name + metadata: Metadata for the task + task_config: Config to use for the SagemakerBuiltinAlgorithms + """ + if ( + task_config is None + or task_config.algorithm_specification is None + or task_config.training_job_resource_config is None + ): + raise ValueError("TaskConfig, algorithm_specification, training_job_resource_config are required") + + input_type = Annotated[ + str, FileExt(self._content_type_to_blob_format(task_config.algorithm_specification.input_content_type)) + ] + + interface = Interface( + # TODO change train and validation to be FlyteDirectory when available + inputs=kwtypes( + static_hyperparameters=dict, train=FlyteDirectory[input_type], validation=FlyteDirectory[input_type] + ), + outputs=kwtypes(model=FlyteFile[self.OUTPUT_TYPE]), + ) + super().__init__( + self._SAGEMAKER_TRAINING_JOB_TASK, + name, + interface=interface, + task_config=task_config, + **kwargs, + ) + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + training_job = _training_job_models.TrainingJob( + algorithm_specification=self._task_config.algorithm_specification, + training_job_resource_config=self._task_config.training_job_resource_config, + ) + return MessageToDict(training_job.to_flyte_idl()) + + def execute(self, **kwargs) -> Any: + raise NotImplementedError( + "Cannot execute Sagemaker Builtin Algorithms locally, for local testing, please mock!" + ) + + @classmethod + def _content_type_to_blob_format(cls, content_type: int) -> str: + """ + TODO Convert InputContentType to Enum and others + """ + if content_type == _training_job_models.InputContentType.TEXT_CSV: + return "csv" + else: + raise ValueError("Unsupported InputContentType: {}".format(content_type)) + + +class SagemakerCustomTrainingTask(PythonFunctionTask[SagemakerTrainingJobConfig]): + """ + Allows a custom training algorithm to be executed on Amazon Sagemaker. For this to work, make sure your container + is built according to Flyte plugin documentation (TODO point the link here) + """ + + _SAGEMAKER_CUSTOM_TRAINING_JOB_TASK = "sagemaker_custom_training_job_task" + + def __init__( + self, + task_config: SagemakerTrainingJobConfig, + task_function: Callable, + **kwargs, + ): + super().__init__( + task_config=task_config, + task_function=task_function, + task_type=self._SAGEMAKER_CUSTOM_TRAINING_JOB_TASK, + **kwargs, + ) + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + training_job = _training_job_models.TrainingJob( + algorithm_specification=self.task_config.algorithm_specification, + training_job_resource_config=self.task_config.training_job_resource_config, + ) + return MessageToDict(training_job.to_flyte_idl()) + + def _is_distributed(self) -> bool: + """ + Only if more than one instance is specified, we assume it is a distributed training setup + """ + return ( + self.task_config.training_job_resource_config + and self.task_config.training_job_resource_config.instance_count > 1 + ) + + def pre_execute(self, user_params: ExecutionParameters) -> ExecutionParameters: + """ + Pre-execute for Sagemaker will automatically add the distributed context to the execution params, only + if the number of execution instances is > 1. Otherwise this is considered to be a single node execution + """ + if self._is_distributed(): + logger.info("Distributed context detected!") + exec_state = FlyteContextManager.current_context().execution_state + if exec_state and exec_state.mode == ExecutionState.Mode.TASK_EXECUTION: + """ + This mode indicates we are actually in a remote execute environment (within sagemaker in this case) + """ + dist_ctx = DistributedTrainingContext.from_env() + else: + dist_ctx = DistributedTrainingContext.local_execute() + return user_params.builder().add_attr("DISTRIBUTED_TRAINING_CONTEXT", dist_ctx).build() + + return user_params + + def post_execute(self, user_params: ExecutionParameters, rval: Any) -> Any: + """ + In the case of distributed execution, we check the should_persist_predicate in the configuration to determine + if the output should be persisted. This is because in distributed training, multiple nodes may produce partial + outputs and only the user process knows the output that should be generated. They can control the choice using + the predicate. + + To control if output is generated across every execution, we override the post_execute method and sometimes + return a None + """ + if self._is_distributed(): + logger.info("Distributed context detected!") + dctx = flytekit.current_context().distributed_training_context + if not self.task_config.should_persist_output(dctx): + logger.info("output persistence predicate not met, Flytekit will ignore outputs") + raise IgnoreOutputs(f"Distributed context - Persistence predicate not met. Ignoring outputs - {dctx}") + return rval + + +# Register the Tensorflow Plugin into the flytekit core plugin system +TaskPlugins.register_pythontask_plugin(SagemakerTrainingJobConfig, SagemakerCustomTrainingTask) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/scripts/flytekit_sagemaker_runner.py b/flytekit/plugins/flytekit-aws-sagemaker/scripts/flytekit_sagemaker_runner.py new file mode 100644 index 0000000000..4a3d94fab5 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/scripts/flytekit_sagemaker_runner.py @@ -0,0 +1,92 @@ +import argparse +import logging +import os +import subprocess +import sys + +FLYTE_ARG_PREFIX = "--__FLYTE" +FLYTE_ENV_VAR_PREFIX = f"{FLYTE_ARG_PREFIX}_ENV_VAR_" +FLYTE_CMD_PREFIX = f"{FLYTE_ARG_PREFIX}_CMD_" +FLYTE_ARG_SUFFIX = "__" + + +# This script is the "entrypoint" script for SageMaker. An environment variable must be set on the container (typically +# in the Dockerfile) of SAGEMAKER_PROGRAM=flytekit_sagemaker_runner.py. When the container is launched in SageMaker, +# it'll run `train flytekit_sagemaker_runner.py `, the responsibility of this script is then to decode +# the known hyperparameters (passed as command line args) to recreate the original command that will actually run the +# virtual environment and execute the intended task (e.g. `service_venv pyflyte-execute --task-module ....`) + +# An example for a valid command: +# python flytekit_sagemaker_runner.py --__FLYTE_ENV_VAR_env1__ val1 --__FLYTE_ENV_VAR_env2__ val2 +# --__FLYTE_CMD_0_service_venv__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_1_pyflyte-execute__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_2_--task-module__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_3_blah__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_4_--task-name__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_5_bloh__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_6_--output-prefix__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_7_s3://fake-bucket__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_8_--inputs__ __FLYTE_CMD_DUMMY_VALUE__ +# --__FLYTE_CMD_9_s3://fake-bucket__ __FLYTE_CMD_DUMMY_VALUE__ + + +def parse_args(cli_args): + parser = argparse.ArgumentParser(description="Running sagemaker task") + args, unknowns = parser.parse_known_args(cli_args) + + # Parse the command line and env vars + flyte_cmd = [] + env_vars = {} + i = 0 + + while i < len(unknowns): + unknown = unknowns[i] + logging.info(f"Processing argument {unknown}") + if unknown.startswith(FLYTE_CMD_PREFIX) and unknown.endswith(FLYTE_ARG_SUFFIX): + processed = unknown[len(FLYTE_CMD_PREFIX) :][: -len(FLYTE_ARG_SUFFIX)] + # Parse the format `1_--task-module` + parts = processed.split("_", maxsplit=1) + flyte_cmd.append((parts[0], parts[1])) + i += 1 + elif unknown.startswith(FLYTE_ENV_VAR_PREFIX) and unknown.endswith(FLYTE_ARG_SUFFIX): + processed = unknown[len(FLYTE_ENV_VAR_PREFIX) :][: -len(FLYTE_ARG_SUFFIX)] + i += 1 + if unknowns[i].startswith(FLYTE_ARG_PREFIX) is False: + env_vars[processed] = unknowns[i] + i += 1 + else: + # To prevent SageMaker from ignoring our __FLYTE_CMD_*__ hyperparameters, we need to set a dummy value + # which serves as a placeholder for each of them. The dummy value placeholder `__FLYTE_CMD_DUMMY_VALUE__` + # falls into this branch and will be ignored + i += 1 + + return flyte_cmd, env_vars + + +def sort_flyte_cmd(flyte_cmd): + # Order the cmd using the index (the first element in each tuple) + flyte_cmd = sorted(flyte_cmd, key=lambda x: int(x[0])) + flyte_cmd = [x[1] for x in flyte_cmd] + return flyte_cmd + + +def set_env_vars(env_vars): + for key, val in env_vars.items(): + os.environ[key] = val + + +def run(cli_args): + flyte_cmd, env_vars = parse_args(cli_args) + flyte_cmd = sort_flyte_cmd(flyte_cmd) + set_env_vars(env_vars) + + logging.info(f"Cmd:{flyte_cmd}") + logging.info(f"Env vars:{env_vars}") + + # Launching a subprocess with the selected entrypoint script and the rest of the arguments + logging.info(f"Launching command: {flyte_cmd}") + subprocess.run(flyte_cmd, stdout=sys.stdout, stderr=sys.stderr, encoding="utf-8", check=True) + + +if __name__ == "__main__": + run(sys.argv) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/setup.py b/flytekit/plugins/flytekit-aws-sagemaker/setup.py new file mode 100644 index 0000000000..855dd32402 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/setup.py @@ -0,0 +1,37 @@ +from setuptools import setup + +PLUGIN_NAME = "awssagemaker" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "sagemaker-training>=3.6.2,<4.0.0", "retry2==0.9.5"] + +__version__ = "0.0.0+develop" + +# TODO: move sagemaker install script here. +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="AWS Plugins for flytekit", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}", f"flytekitplugins.{PLUGIN_NAME}.models"], + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + scripts=["scripts/flytekit_sagemaker_runner.py"], +) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/__init__.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/test_flytekit_sagemaker_running.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_flytekit_sagemaker_running.py new file mode 100644 index 0000000000..f527deb5cc --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_flytekit_sagemaker_running.py @@ -0,0 +1,37 @@ +import os +import sys +from unittest import mock + +from scripts.flytekit_sagemaker_runner import run as _flyte_sagemaker_run + +cmd = [] +cmd.extend(["--__FLYTE_ENV_VAR_env1__", "val1"]) +cmd.extend(["--__FLYTE_ENV_VAR_env2__", "val2"]) +cmd.extend(["--__FLYTE_CMD_0_service_venv__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_1_pyflyte-execute__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_2_--task-module__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_3_blah__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_4_--task-name__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_5_bloh__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_6_--output-prefix__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_7_s3://fake-bucket__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_8_--inputs__", "__FLYTE_CMD_DUMMY_VALUE__"]) +cmd.extend(["--__FLYTE_CMD_9_s3://fake-bucket__", "__FLYTE_CMD_DUMMY_VALUE__"]) + + +@mock.patch.dict("os.environ") +@mock.patch("subprocess.run") +def test(mock_subprocess_run): + _flyte_sagemaker_run(cmd) + assert "env1" in os.environ + assert "env2" in os.environ + assert os.environ["env1"] == "val1" + assert os.environ["env2"] == "val2" + mock_subprocess_run.assert_called_with( + "service_venv pyflyte-execute --task-module blah --task-name bloh " + "--output-prefix s3://fake-bucket --inputs s3://fake-bucket".split(), + stdout=sys.stdout, + stderr=sys.stderr, + encoding="utf-8", + check=True, + ) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo.py new file mode 100644 index 0000000000..e52994c664 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo.py @@ -0,0 +1,124 @@ +import os +import pathlib +import tempfile + +import pytest +from flytekitplugins.awssagemaker.hpo import ( + HPOJob, + HPOTuningJobConfigTransformer, + ParameterRangesTransformer, + SagemakerHPOTask, +) +from flytekitplugins.awssagemaker.models.hpo_job import ( + HyperparameterTuningJobConfig, + HyperparameterTuningObjective, + HyperparameterTuningObjectiveType, + TrainingJobEarlyStoppingType, +) +from flytekitplugins.awssagemaker.models.parameter_ranges import IntegerParameterRange, ParameterRangeOneOf +from flytekitplugins.awssagemaker.models.training_job import ( + AlgorithmName, + AlgorithmSpecification, + TrainingJobResourceConfig, +) +from flytekitplugins.awssagemaker.training import SagemakerBuiltinAlgorithmsTask, SagemakerTrainingJobConfig + +from flytekit import FlyteContext +from flytekit.models.types import LiteralType, SimpleType + +from .test_training import _get_reg_settings + + +def test_hpo_for_builtin(): + trainer = SagemakerBuiltinAlgorithmsTask( + name="builtin-trainer", + task_config=SagemakerTrainingJobConfig( + training_job_resource_config=TrainingJobResourceConfig( + instance_count=1, + instance_type="ml-xlarge", + volume_size_in_gb=1, + ), + algorithm_specification=AlgorithmSpecification( + algorithm_name=AlgorithmName.XGBOOST, + ), + ), + ) + + hpo = SagemakerHPOTask( + name="test", + task_config=HPOJob(10, 10, ["x"]), + training_task=trainer, + ) + + assert hpo.python_interface.inputs.keys() == { + "static_hyperparameters", + "train", + "validation", + "hyperparameter_tuning_job_config", + "x", + } + assert hpo.python_interface.outputs.keys() == {"model"} + + assert hpo.get_custom(_get_reg_settings()) == { + "maxNumberOfTrainingJobs": "10", + "maxParallelTrainingJobs": "10", + "trainingJob": { + "algorithmSpecification": {"algorithmName": "XGBOOST"}, + "trainingJobResourceConfig": {"instanceCount": "1", "instanceType": "ml-xlarge", "volumeSizeInGb": "1"}, + }, + } + + with pytest.raises(NotImplementedError): + with tempfile.TemporaryDirectory() as tmp: + x = pathlib.Path(os.path.join(tmp, "x")) + y = pathlib.Path(os.path.join(tmp, "y")) + x.mkdir(parents=True, exist_ok=True) + y.mkdir(parents=True, exist_ok=True) + + hpo( + static_hyperparameters={}, + train=f"{x}", # file transformer doesn't handle pathlib.Path yet + validation=f"{y}", # file transformer doesn't handle pathlib.Path yet + hyperparameter_tuning_job_config=HyperparameterTuningJobConfig( + tuning_strategy=1, + tuning_objective=HyperparameterTuningObjective( + objective_type=HyperparameterTuningObjectiveType.MINIMIZE, + metric_name="x", + ), + training_job_early_stopping_type=TrainingJobEarlyStoppingType.OFF, + ), + x=ParameterRangeOneOf(param=IntegerParameterRange(10, 1, 1)), + ) + + +def test_hpoconfig_transformer(): + t = HPOTuningJobConfigTransformer() + assert t.get_literal_type(HyperparameterTuningJobConfig) == LiteralType(simple=SimpleType.STRUCT) + o = HyperparameterTuningJobConfig( + tuning_strategy=1, + tuning_objective=HyperparameterTuningObjective( + objective_type=HyperparameterTuningObjectiveType.MINIMIZE, + metric_name="x", + ), + training_job_early_stopping_type=TrainingJobEarlyStoppingType.OFF, + ) + ctx = FlyteContext.current_context() + lit = t.to_literal(ctx, python_val=o, python_type=HyperparameterTuningJobConfig, expected=None) + assert lit is not None + assert lit.scalar.generic is not None + ro = t.to_python_value(ctx, lit, HyperparameterTuningJobConfig) + assert ro is not None + assert ro == o + + +def test_parameter_ranges_transformer(): + t = ParameterRangesTransformer() + assert t.get_literal_type(ParameterRangeOneOf) == LiteralType(simple=SimpleType.STRUCT) + o = ParameterRangeOneOf(param=IntegerParameterRange(10, 0, 1)) + ctx = FlyteContext.current_context() + lit = t.to_literal(ctx, python_val=o, python_type=ParameterRangeOneOf, expected=None) + assert lit is not None + assert lit.scalar.generic is not None + ro = t.to_python_value(ctx, lit, ParameterRangeOneOf) + assert ro is not None + assert ro == o diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo_job.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo_job.py new file mode 100644 index 0000000000..494eecd2ab --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_hpo_job.py @@ -0,0 +1,79 @@ +from flytekitplugins.awssagemaker.models import hpo_job, training_job + + +def test_hyperparameter_tuning_objective(): + obj = hpo_job.HyperparameterTuningObjective( + objective_type=hpo_job.HyperparameterTuningObjectiveType.MAXIMIZE, metric_name="test_metric" + ) + obj2 = hpo_job.HyperparameterTuningObjective.from_flyte_idl(obj.to_flyte_idl()) + + assert obj == obj2 + + +def test_hyperparameter_job_config(): + jc = hpo_job.HyperparameterTuningJobConfig( + tuning_strategy=hpo_job.HyperparameterTuningStrategy.BAYESIAN, + tuning_objective=hpo_job.HyperparameterTuningObjective( + objective_type=hpo_job.HyperparameterTuningObjectiveType.MAXIMIZE, metric_name="test_metric" + ), + training_job_early_stopping_type=hpo_job.TrainingJobEarlyStoppingType.AUTO, + ) + + jc2 = hpo_job.HyperparameterTuningJobConfig.from_flyte_idl(jc.to_flyte_idl()) + assert jc2.tuning_strategy == jc.tuning_strategy + assert jc2.tuning_objective == jc.tuning_objective + assert jc2.training_job_early_stopping_type == jc.training_job_early_stopping_type + + +def test_hyperparameter_tuning_job(): + rc = training_job.TrainingJobResourceConfig( + instance_type="test_type", + instance_count=10, + volume_size_in_gb=25, + distributed_protocol=training_job.DistributedProtocol.MPI, + ) + alg = training_job.AlgorithmSpecification( + algorithm_name=training_job.AlgorithmName.CUSTOM, + algorithm_version="", + input_mode=training_job.InputMode.FILE, + input_content_type=training_job.InputContentType.TEXT_CSV, + ) + tj = training_job.TrainingJob( + training_job_resource_config=rc, + algorithm_specification=alg, + ) + hpo = hpo_job.HyperparameterTuningJob(max_number_of_training_jobs=10, max_parallel_training_jobs=5, training_job=tj) + + hpo2 = hpo_job.HyperparameterTuningJob.from_flyte_idl(hpo.to_flyte_idl()) + + assert hpo.max_number_of_training_jobs == hpo2.max_number_of_training_jobs + assert hpo.max_parallel_training_jobs == hpo2.max_parallel_training_jobs + assert ( + hpo2.training_job.training_job_resource_config.instance_type + == hpo.training_job.training_job_resource_config.instance_type + ) + assert ( + hpo2.training_job.training_job_resource_config.instance_count + == hpo.training_job.training_job_resource_config.instance_count + ) + assert ( + hpo2.training_job.training_job_resource_config.distributed_protocol + == hpo.training_job.training_job_resource_config.distributed_protocol + ) + assert ( + hpo2.training_job.training_job_resource_config.volume_size_in_gb + == hpo.training_job.training_job_resource_config.volume_size_in_gb + ) + assert ( + hpo2.training_job.algorithm_specification.algorithm_name + == hpo.training_job.algorithm_specification.algorithm_name + ) + assert ( + hpo2.training_job.algorithm_specification.algorithm_version + == hpo.training_job.algorithm_specification.algorithm_version + ) + assert hpo2.training_job.algorithm_specification.input_mode == hpo.training_job.algorithm_specification.input_mode + assert ( + hpo2.training_job.algorithm_specification.input_content_type + == hpo.training_job.algorithm_specification.input_content_type + ) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/test_parameter_ranges.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_parameter_ranges.py new file mode 100644 index 0000000000..6d33388c33 --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_parameter_ranges.py @@ -0,0 +1,93 @@ +import unittest + +import pytest +from flytekitplugins.awssagemaker.models import parameter_ranges + + +# assert statements cannot be written inside lambda expressions. This is a convenient function to work around that. +def assert_equal(a, b): + assert a == b + + +def test_continuous_parameter_range(): + pr = parameter_ranges.ContinuousParameterRange( + max_value=10, min_value=0.5, scaling_type=parameter_ranges.HyperparameterScalingType.REVERSELOGARITHMIC + ) + + pr2 = parameter_ranges.ContinuousParameterRange.from_flyte_idl(pr.to_flyte_idl()) + assert pr == pr2 + assert type(pr2.max_value) == float + assert type(pr2.min_value) == float + assert pr2.max_value == 10.0 + assert pr2.min_value == 0.5 + assert pr2.scaling_type == parameter_ranges.HyperparameterScalingType.REVERSELOGARITHMIC + + +def test_integer_parameter_range(): + pr = parameter_ranges.IntegerParameterRange( + max_value=1, min_value=0, scaling_type=parameter_ranges.HyperparameterScalingType.LOGARITHMIC + ) + + pr2 = parameter_ranges.IntegerParameterRange.from_flyte_idl(pr.to_flyte_idl()) + assert pr == pr2 + assert type(pr2.max_value) == int + assert type(pr2.min_value) == int + assert pr2.max_value == 1 + assert pr2.min_value == 0 + assert pr2.scaling_type == parameter_ranges.HyperparameterScalingType.LOGARITHMIC + + +def test_categorical_parameter_range(): + case = unittest.TestCase() + pr = parameter_ranges.CategoricalParameterRange(values=["abc", "cat"]) + + pr2 = parameter_ranges.CategoricalParameterRange.from_flyte_idl(pr.to_flyte_idl()) + assert pr == pr2 + assert isinstance(pr2.values, list) + case.assertCountEqual(pr2.values, pr.values) + + +def test_parameter_ranges(): + pr = parameter_ranges.ParameterRanges( + { + "a": parameter_ranges.CategoricalParameterRange(values=["a-1", "a-2"]), + "b": parameter_ranges.IntegerParameterRange( + min_value=1, max_value=5, scaling_type=parameter_ranges.HyperparameterScalingType.LINEAR + ), + "c": parameter_ranges.ContinuousParameterRange( + min_value=0.1, max_value=1.0, scaling_type=parameter_ranges.HyperparameterScalingType.LOGARITHMIC + ), + }, + ) + pr2 = parameter_ranges.ParameterRanges.from_flyte_idl(pr.to_flyte_idl()) + assert pr == pr2 + + +LIST_OF_PARAMETERS = [ + ( + parameter_ranges.IntegerParameterRange( + min_value=1, max_value=5, scaling_type=parameter_ranges.HyperparameterScalingType.LINEAR + ), + lambda param: assert_equal(param.integer_parameter_range.max_value, 5), + ), + ( + parameter_ranges.ContinuousParameterRange( + min_value=0.1, max_value=1.0, scaling_type=parameter_ranges.HyperparameterScalingType.LOGARITHMIC + ), + lambda param: assert_equal(param.continuous_parameter_range.max_value, 1), + ), + ( + parameter_ranges.CategoricalParameterRange(values=["a-1", "a-2"]), + lambda param: assert_equal(len(param.categorical_parameter_range.values), 2), + ), +] + + +@pytest.mark.parametrize("param_tuple", LIST_OF_PARAMETERS) +def test_parameter_ranges_oneof(param_tuple): + param, assertion = param_tuple + oneof = parameter_ranges.ParameterRangeOneOf(param=param) + oneof2 = parameter_ranges.ParameterRangeOneOf.from_flyte_idl(oneof.to_flyte_idl()) + assert oneof2 == oneof + assertion(oneof) + assertion(oneof2) diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/test_training.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_training.py new file mode 100644 index 0000000000..4d33a9e4bb --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_training.py @@ -0,0 +1,134 @@ +import os +import pathlib +import tempfile + +import pytest +from flytekitplugins.awssagemaker.distributed_training import setup_envars_for_testing +from flytekitplugins.awssagemaker.models.training_job import ( + AlgorithmName, + AlgorithmSpecification, + DistributedProtocol, + TrainingJobResourceConfig, +) +from flytekitplugins.awssagemaker.training import SagemakerBuiltinAlgorithmsTask, SagemakerTrainingJobConfig + +import flytekit +from flytekit import task +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.core.context_manager import ExecutionParameters + + +def _get_reg_settings(): + 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]), + ) + return settings + + +def test_builtin_training(): + trainer = SagemakerBuiltinAlgorithmsTask( + name="builtin-trainer", + task_config=SagemakerTrainingJobConfig( + training_job_resource_config=TrainingJobResourceConfig( + instance_count=1, + instance_type="ml-xlarge", + volume_size_in_gb=1, + ), + algorithm_specification=AlgorithmSpecification( + algorithm_name=AlgorithmName.XGBOOST, + ), + ), + ) + + assert trainer.python_interface.inputs.keys() == {"static_hyperparameters", "train", "validation"} + assert trainer.python_interface.outputs.keys() == {"model"} + + with tempfile.TemporaryDirectory() as tmp: + x = pathlib.Path(os.path.join(tmp, "x")) + y = pathlib.Path(os.path.join(tmp, "y")) + x.mkdir(parents=True, exist_ok=True) + y.mkdir(parents=True, exist_ok=True) + with pytest.raises(NotImplementedError): + # Type engine doesn't support pathlib.Path yet + trainer(static_hyperparameters={}, train=f"{x}", validation=f"{y}") + + assert trainer.get_custom(_get_reg_settings()) == { + "algorithmSpecification": {"algorithmName": "XGBOOST"}, + "trainingJobResourceConfig": {"instanceCount": "1", "instanceType": "ml-xlarge", "volumeSizeInGb": "1"}, + } + + +def test_custom_training(): + @task( + task_config=SagemakerTrainingJobConfig( + training_job_resource_config=TrainingJobResourceConfig( + instance_type="ml-xlarge", + volume_size_in_gb=1, + ), + algorithm_specification=AlgorithmSpecification( + algorithm_name=AlgorithmName.CUSTOM, + ), + ) + ) + def my_custom_trainer(x: int) -> int: + return x + + assert my_custom_trainer.python_interface.inputs == {"x": int} + assert my_custom_trainer.python_interface.outputs == {"o0": int} + + assert my_custom_trainer(x=10) == 10 + + assert my_custom_trainer.get_custom(_get_reg_settings()) == { + "algorithmSpecification": {}, + "trainingJobResourceConfig": {"instanceCount": "1", "instanceType": "ml-xlarge", "volumeSizeInGb": "1"}, + } + + +def test_distributed_custom_training(): + setup_envars_for_testing() + + @task( + task_config=SagemakerTrainingJobConfig( + training_job_resource_config=TrainingJobResourceConfig( + instance_type="ml-xlarge", + volume_size_in_gb=1, + instance_count=2, # Indicates distributed training + distributed_protocol=DistributedProtocol.MPI, + ), + algorithm_specification=AlgorithmSpecification( + algorithm_name=AlgorithmName.CUSTOM, + ), + ) + ) + def my_custom_trainer(x: int) -> int: + assert flytekit.current_context().distributed_training_context is not None + return x + + assert my_custom_trainer.python_interface.inputs == {"x": int} + assert my_custom_trainer.python_interface.outputs == {"o0": int} + + assert my_custom_trainer(x=10) == 10 + + assert my_custom_trainer._is_distributed() is True + + pb = ExecutionParameters.new_builder() + pb.working_dir = "/tmp" + p = pb.build() + new_p = my_custom_trainer.pre_execute(p) + assert new_p is not None + assert new_p.has_attr("distributed_training_context") + + assert my_custom_trainer.get_custom(_get_reg_settings()) == { + "algorithmSpecification": {}, + "trainingJobResourceConfig": { + "distributedProtocol": "MPI", + "instanceCount": "2", + "instanceType": "ml-xlarge", + "volumeSizeInGb": "1", + }, + } diff --git a/flytekit/plugins/flytekit-aws-sagemaker/tests/test_training_job.py b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_training_job.py new file mode 100644 index 0000000000..8774857b1f --- /dev/null +++ b/flytekit/plugins/flytekit-aws-sagemaker/tests/test_training_job.py @@ -0,0 +1,87 @@ +import unittest + +from flytekitplugins.awssagemaker.models import training_job + + +def test_training_job_resource_config(): + rc = training_job.TrainingJobResourceConfig( + instance_count=1, + instance_type="random.instance", + volume_size_in_gb=25, + distributed_protocol=training_job.DistributedProtocol.MPI, + ) + + rc2 = training_job.TrainingJobResourceConfig.from_flyte_idl(rc.to_flyte_idl()) + assert rc2 == rc + assert rc2.distributed_protocol == training_job.DistributedProtocol.MPI + assert rc != training_job.TrainingJobResourceConfig( + instance_count=1, + instance_type="random.instance", + volume_size_in_gb=25, + distributed_protocol=training_job.DistributedProtocol.UNSPECIFIED, + ) + + assert rc != training_job.TrainingJobResourceConfig( + instance_count=1, + instance_type="oops", + volume_size_in_gb=25, + distributed_protocol=training_job.DistributedProtocol.MPI, + ) + + +def test_metric_definition(): + md = training_job.MetricDefinition(name="test-metric", regex="[a-zA-Z]*") + + md2 = training_job.MetricDefinition.from_flyte_idl(md.to_flyte_idl()) + assert md == md2 + assert md2.name == "test-metric" + assert md2.regex == "[a-zA-Z]*" + + +def test_algorithm_specification(): + case = unittest.TestCase() + alg_spec = training_job.AlgorithmSpecification( + algorithm_name=training_job.AlgorithmName.CUSTOM, + algorithm_version="v100", + input_mode=training_job.InputMode.FILE, + metric_definitions=[training_job.MetricDefinition(name="a", regex="b")], + input_content_type=training_job.InputContentType.TEXT_CSV, + ) + + alg_spec2 = training_job.AlgorithmSpecification.from_flyte_idl(alg_spec.to_flyte_idl()) + + assert alg_spec2.algorithm_name == training_job.AlgorithmName.CUSTOM + assert alg_spec2.algorithm_version == "v100" + assert alg_spec2.input_mode == training_job.InputMode.FILE + case.assertCountEqual(alg_spec.metric_definitions, alg_spec2.metric_definitions) + assert alg_spec == alg_spec2 + + +def test_training_job(): + rc = training_job.TrainingJobResourceConfig( + instance_type="test_type", + instance_count=10, + volume_size_in_gb=25, + distributed_protocol=training_job.DistributedProtocol.MPI, + ) + alg = training_job.AlgorithmSpecification( + algorithm_name=training_job.AlgorithmName.CUSTOM, + algorithm_version="", + input_mode=training_job.InputMode.FILE, + input_content_type=training_job.InputContentType.TEXT_CSV, + ) + tj = training_job.TrainingJob( + training_job_resource_config=rc, + algorithm_specification=alg, + ) + + tj2 = training_job.TrainingJob.from_flyte_idl(tj.to_flyte_idl()) + # checking tj == tj2 would return false because we don't have the __eq__ magic method defined + assert tj.training_job_resource_config.instance_type == tj2.training_job_resource_config.instance_type + assert tj.training_job_resource_config.instance_count == tj2.training_job_resource_config.instance_count + assert tj.training_job_resource_config.distributed_protocol == tj2.training_job_resource_config.distributed_protocol + assert tj.training_job_resource_config.volume_size_in_gb == tj2.training_job_resource_config.volume_size_in_gb + assert tj.algorithm_specification.algorithm_name == tj2.algorithm_specification.algorithm_name + assert tj.algorithm_specification.algorithm_version == tj2.algorithm_specification.algorithm_version + assert tj.algorithm_specification.input_mode == tj2.algorithm_specification.input_mode + assert tj.algorithm_specification.input_content_type == tj2.algorithm_specification.input_content_type diff --git a/flytekit/plugins/flytekit-bigquery/README.md b/flytekit/plugins/flytekit-bigquery/README.md new file mode 100644 index 0000000000..7b8468ffc2 --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/README.md @@ -0,0 +1,11 @@ +# Flytekit BigQuery Plugin + +BigQuery enables us to build data-intensive applications without operational burden. Flyte backend can be connected with the BigQuery service. Once enabled, it can allow you to query a BigQuery table. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-bigquery +``` + +To configure BigQuery in the Flyte deployment's backend, follow the [configuration guide](https://docs.flyte.org/en/latest/deployment/plugin_setup/gcp/bigquery.html#deployment-plugin-setup-gcp-bigquery). diff --git a/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py b/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py new file mode 100644 index 0000000000..0e0fe80bc7 --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/__init__.py @@ -0,0 +1,16 @@ +""" +.. currentmodule:: flytekitplugins.bigquery + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + BigQueryConfig + BigQueryTask + BigQueryAgent +""" + +from .agent import BigQueryAgent +from .task import BigQueryConfig, BigQueryTask diff --git a/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/agent.py b/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/agent.py new file mode 100644 index 0000000000..4c34285793 --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/agent.py @@ -0,0 +1,126 @@ +import datetime +import json +from dataclasses import asdict, dataclass +from typing import Dict, Optional + +from flyteidl.admin.agent_pb2 import ( + CreateTaskResponse, + DeleteTaskResponse, + GetTaskResponse, + Resource, +) +from flyteidl.core.execution_pb2 import TaskExecution +from google.cloud import bigquery + +from flytekit import FlyteContextManager, StructuredDataset, logger +from flytekit.core.type_engine import TypeEngine +from flytekit.extend.backend.base_agent import AgentBase, AgentRegistry, convert_to_flyte_phase +from flytekit.models import literals +from flytekit.models.core.execution import TaskLog +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", +} + + +@dataclass +class Metadata: + job_id: str + project: str + location: str + + +class BigQueryAgent(AgentBase): + name = "Bigquery Agent" + + def __init__(self): + super().__init__(task_type="bigquery_query_job_task") + + def create( + self, + output_prefix: str, + task_template: TaskTemplate, + inputs: Optional[LiteralMap] = None, + **kwargs, + ) -> CreateTaskResponse: + 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 + project = custom["ProjectID"] + location = custom["Location"] + client = bigquery.Client(project=project, location=location) + query_job = client.query(task_template.sql.statement, job_config=job_config) + metadata = Metadata(job_id=str(query_job.job_id), location=location, project=project) + + return CreateTaskResponse(resource_meta=json.dumps(asdict(metadata)).encode("utf-8")) + + def get(self, resource_meta: bytes, **kwargs) -> GetTaskResponse: + client = bigquery.Client() + metadata = Metadata(**json.loads(resource_meta.decode("utf-8"))) + log_links = [ + TaskLog( + uri=f"https://console.cloud.google.com/bigquery?project={metadata.project}&j=bq:{metadata.location}:{metadata.job_id}&page=queryresults", + name="BigQuery Console", + ).to_flyte_idl() + ] + + job = client.get_job(metadata.job_id, metadata.project, metadata.location) + if job.errors: + logger.error("failed to run BigQuery job with error:", job.errors.__str__()) + return GetTaskResponse( + resource=Resource(state=TaskExecution.FAILED, message=job.errors.__str__()), log_links=log_links + ) + + cur_phase = convert_to_flyte_phase(str(job.state)) + res = None + + if cur_phase == TaskExecution.SUCCEEDED: + ctx = FlyteContextManager.current_context() + if job.destination: + 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="")), + ) + } + ).to_flyte_idl() + + return GetTaskResponse(resource=Resource(phase=cur_phase, outputs=res), log_links=log_links) + + def delete(self, resource_meta: bytes, **kwargs) -> DeleteTaskResponse: + client = bigquery.Client() + metadata = Metadata(**json.loads(resource_meta.decode("utf-8"))) + client.cancel_job(metadata.job_id, metadata.project, metadata.location) + return DeleteTaskResponse() + + +AgentRegistry.register(BigQueryAgent()) diff --git a/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py b/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py new file mode 100644 index 0000000000..5ae03b3f88 --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/flytekitplugins/bigquery/task.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type + +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Struct + +from flytekit import lazy_module +from flytekit.configuration import SerializationSettings +from flytekit.extend import SQLTask +from flytekit.extend.backend.base_agent import AsyncAgentExecutorMixin +from flytekit.models import task as _task_model +from flytekit.types.structured import StructuredDataset + +bigquery = lazy_module("google.cloud.bigquery") + + +@dataclass +class BigQueryConfig(object): + """ + BigQueryConfig should be used to configure a BigQuery Task. + """ + + ProjectID: str + Location: Optional[str] = None + QueryJobConfig: Optional[bigquery.QueryJobConfig] = None + + +class BigQueryTask(AsyncAgentExecutorMixin, SQLTask[BigQueryConfig]): + """ + This is the simplest form of a BigQuery Task, that can be used even for tasks that do not produce any output. + """ + + # This task is executed using the BigQuery handler in the backend. + # https://github.com/flyteorg/flyteplugins/blob/43623826fb189fa64dc4cb53e7025b517d911f22/go/tasks/plugins/webapi/bigquery/plugin.go#L34 + _TASK_TYPE = "bigquery_query_job_task" + + def __init__( + self, + name: str, + query_template: str, + task_config: Optional[BigQueryConfig], + inputs: Optional[Dict[str, Type]] = None, + output_structured_dataset_type: Optional[Type[StructuredDataset]] = None, + **kwargs, + ): + """ + To be used to query BigQuery Tables. + + :param name: Name of this task, should be unique in the project + :param query_template: The actual query to run. We use Flyte's Golang templating format for Query templating. Refer to the templating documentation + :param task_config: BigQueryConfig object + :param inputs: Name and type of inputs specified as an ordered dictionary + :param output_structured_dataset_type: If some data is produced by this query, then you can specify the output StructuredDataset type + :param kwargs: All other args required by Parent type - SQLTask + """ + outputs = None + if output_structured_dataset_type is not None: + outputs = { + "results": output_structured_dataset_type, + } + super().__init__( + name=name, + task_config=task_config, + query_template=query_template, + inputs=inputs, + outputs=outputs, + task_type=self._TASK_TYPE, + **kwargs, + ) + self._output_structured_dataset_type = output_structured_dataset_type + + def get_custom(self, settings: SerializationSettings) -> Dict[str, Any]: + config = { + "Location": self.task_config.Location, + "ProjectID": self.task_config.ProjectID, + } + if self.task_config.QueryJobConfig is not None: + config.update(self.task_config.QueryJobConfig.to_api_repr()["query"]) + s = Struct() + s.update(config) + return json_format.MessageToDict(s) + + 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 diff --git a/flytekit/plugins/flytekit-bigquery/setup.py b/flytekit/plugins/flytekit-bigquery/setup.py new file mode 100644 index 0000000000..10dd3c7ca5 --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup + +PLUGIN_NAME = "bigquery" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "google-cloud-bigquery", "flyteidl>=v1.10.6"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package holds the Bigquery plugins for flytekit", + 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", + "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/flytekit/plugins/flytekit-bigquery/tests/__init__.py b/flytekit/plugins/flytekit-bigquery/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-bigquery/tests/test_agent.py b/flytekit/plugins/flytekit-bigquery/tests/test_agent.py new file mode 100644 index 0000000000..dc2af4ab80 --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/tests/test_agent.py @@ -0,0 +1,105 @@ +import json +from dataclasses import asdict +from datetime import timedelta +from unittest import mock + +from flyteidl.core.execution_pb2 import TaskExecution +from flytekitplugins.bigquery.agent import Metadata + +import flytekit.models.interface as interface_models +from flytekit.extend.backend.base_agent import AgentRegistry +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_agent(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() + + setattr(MockJob, "errors", None) + + mock_instance.get_job.return_value = MockJob() + mock_instance.query.return_value = MockJob() + mock_instance.cancel_job.return_value = MockJob() + + agent = AgentRegistry.get_agent("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"), + ) + + metadata_bytes = json.dumps( + asdict(Metadata(job_id="dummy_id", project="dummy_project", location="us-central1")) + ).encode("utf-8") + assert agent.create("/tmp", dummy_template, task_inputs).resource_meta == metadata_bytes + res = agent.get(metadata_bytes) + assert res.resource.phase == TaskExecution.SUCCEEDED + assert ( + res.resource.outputs.literals["results"].scalar.structured_dataset.uri + == "bq://dummy_project:dummy_dataset.dummy_table" + ) + assert res.log_links[0].name == "BigQuery Console" + assert ( + res.log_links[0].uri + == "https://console.cloud.google.com/bigquery?project=dummy_project&j=bq:us-central1:dummy_id&page=queryresults" + ) + agent.delete(metadata_bytes) + mock_instance.cancel_job.assert_called() diff --git a/flytekit/plugins/flytekit-bigquery/tests/test_bigquery.py b/flytekit/plugins/flytekit-bigquery/tests/test_bigquery.py new file mode 100644 index 0000000000..7f4837ae0d --- /dev/null +++ b/flytekit/plugins/flytekit-bigquery/tests/test_bigquery.py @@ -0,0 +1,72 @@ +from collections import OrderedDict + +import pytest +from flytekitplugins.bigquery import BigQueryConfig, BigQueryTask +from google.cloud.bigquery import QueryJobConfig +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Struct + +from flytekit import StructuredDataset, kwtypes, workflow +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.extend import get_serializable + +query_template = "SELECT * FROM `bigquery-public-data.crypto_dogecoin.transactions` WHERE @version = 1 LIMIT 10" + + +def test_serialization(): + bigquery_task = BigQueryTask( + name="flytekit.demo.bigquery_task.query", + inputs=kwtypes(ds=str), + task_config=BigQueryConfig( + ProjectID="Flyte", Location="Asia", QueryJobConfig=QueryJobConfig(allow_large_results=True) + ), + query_template=query_template, + output_structured_dataset_type=StructuredDataset, + ) + + @workflow + def my_wf(ds: str) -> StructuredDataset: + return bigquery_task(ds=ds) + + default_img = Image(name="default", fqn="test", tag="tag") + serialization_settings = SerializationSettings( + project="proj", + domain="dom", + version="123", + image_config=ImageConfig(default_image=default_img, images=[default_img]), + env={}, + ) + + task_spec = get_serializable(OrderedDict(), serialization_settings, bigquery_task) + + assert "SELECT * FROM `bigquery-public-data.crypto_dogecoin.transactions`" in task_spec.template.sql.statement + assert "@version" in task_spec.template.sql.statement + assert task_spec.template.sql.dialect == task_spec.template.sql.Dialect.ANSI + s = Struct() + s.update({"ProjectID": "Flyte", "Location": "Asia", "allowLargeResults": True}) + assert task_spec.template.custom == json_format.MessageToDict(s) + assert len(task_spec.template.interface.inputs) == 1 + assert len(task_spec.template.interface.outputs) == 1 + + admin_workflow_spec = get_serializable(OrderedDict(), serialization_settings, my_wf) + assert admin_workflow_spec.template.interface.outputs["o0"].type.structured_dataset_type is not None + assert admin_workflow_spec.template.outputs[0].var == "o0" + assert admin_workflow_spec.template.outputs[0].binding.promise.node_id == "n0" + assert admin_workflow_spec.template.outputs[0].binding.promise.var == "results" + + +def test_local_exec(): + bigquery_task = BigQueryTask( + name="flytekit.demo.bigquery_task.query2", + inputs=kwtypes(ds=str), + query_template=query_template, + task_config=BigQueryConfig(ProjectID="Flyte", Location="Asia"), + output_structured_dataset_type=StructuredDataset, + ) + + assert len(bigquery_task.interface.inputs) == 1 + assert len(bigquery_task.interface.outputs) == 1 + + # will not run locally + with pytest.raises(Exception): + bigquery_task() diff --git a/flytekit/plugins/flytekit-dask/README.md b/flytekit/plugins/flytekit-dask/README.md new file mode 100644 index 0000000000..9d645bcd27 --- /dev/null +++ b/flytekit/plugins/flytekit-dask/README.md @@ -0,0 +1,21 @@ +# Flytekit Dask Plugin + +Flyte can execute `dask` jobs natively on a Kubernetes Cluster, which manages the virtual `dask` cluster's lifecycle +(spin-up and tear down). It leverages the open-source Kubernetes Dask Operator and can be enabled without signing up +for any service. This is like running a transient (ephemeral) `dask` cluster - a type of cluster spun up for a specific +task and torn down after completion. This helps in making sure that the Python environment is the same on the job-runner +(driver), scheduler and the workers. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-dask +``` + +To configure Dask in the Flyte deployment's backed, follow +[step 1](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/kubernetes/k8s_dask/index.html#step-1-deploy-the-dask-plugin-in-the-flyte-backend) +and +[step 2](https://docs.flyte.org/projects/cookbook/en/latest/auto/auto/integrations/kubernetes/k8s_dask/index.html#step-2-environment-setup) + +An [example](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/kubernetes/k8s_dask/index.html) +can be found in the documentation. diff --git a/flytekit/plugins/flytekit-dask/flytekitplugins/dask/__init__.py b/flytekit/plugins/flytekit-dask/flytekitplugins/dask/__init__.py new file mode 100644 index 0000000000..ccadf385fc --- /dev/null +++ b/flytekit/plugins/flytekit-dask/flytekitplugins/dask/__init__.py @@ -0,0 +1,15 @@ +""" +.. currentmodule:: flytekitplugins.dask + +This package contains the Python related side of the Dask Plugin + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + Dask + Scheduler + WorkerGroup +""" + +from flytekitplugins.dask.task import Dask, Scheduler, WorkerGroup diff --git a/flytekit/plugins/flytekit-dask/flytekitplugins/dask/models.py b/flytekit/plugins/flytekit-dask/flytekitplugins/dask/models.py new file mode 100644 index 0000000000..b833ab660a --- /dev/null +++ b/flytekit/plugins/flytekit-dask/flytekitplugins/dask/models.py @@ -0,0 +1,134 @@ +from typing import Optional + +from flyteidl.plugins import dask_pb2 as dask_task + +from flytekit.models import common as common +from flytekit.models import task as task + + +class Scheduler(common.FlyteIdlEntity): + """ + Configuration for the scheduler pod + + :param image: Optional image to use. + :param resources: Optional resources to use. + """ + + def __init__(self, image: Optional[str] = None, resources: Optional[task.Resources] = None): + self._image = image + self._resources = resources + + @property + def image(self) -> Optional[str]: + """ + :return: The optional image for the scheduler pod + """ + return self._image + + @property + def resources(self) -> Optional[task.Resources]: + """ + :return: Optional resources for the scheduler pod + """ + return self._resources + + def to_flyte_idl(self) -> dask_task.DaskScheduler: + """ + :return: The scheduler spec serialized to protobuf + """ + return dask_task.DaskScheduler( + image=self.image, + resources=self.resources.to_flyte_idl() if self.resources else None, + ) + + +class WorkerGroup(common.FlyteIdlEntity): + """ + Configuration for a dask worker group + + :param number_of_workers:Number of workers in the group + :param image: Optional image to use for the pods of the worker group + :param resources: Optional resources to use for the pods of the worker group + """ + + def __init__( + self, + number_of_workers: int, + image: Optional[str] = None, + resources: Optional[task.Resources] = None, + ): + if number_of_workers < 1: + raise ValueError( + f"Each worker group needs to have at least one worker, but {number_of_workers} have been specified." + ) + + self._number_of_workers = number_of_workers + self._image = image + self._resources = resources + + @property + def number_of_workers(self) -> Optional[int]: + """ + :return: Optional number of workers for the worker group + """ + return self._number_of_workers + + @property + def image(self) -> Optional[str]: + """ + :return: The optional image to use for the worker pods + """ + return self._image + + @property + def resources(self) -> Optional[task.Resources]: + """ + :return: Optional resources to use for the worker pods + """ + return self._resources + + def to_flyte_idl(self) -> dask_task.DaskWorkerGroup: + """ + :return: The dask cluster serialized to protobuf + """ + return dask_task.DaskWorkerGroup( + number_of_workers=self.number_of_workers, + image=self.image, + resources=self.resources.to_flyte_idl() if self.resources else None, + ) + + +class DaskJob(common.FlyteIdlEntity): + """ + Configuration for the custom dask job to run + + :param scheduler: Configuration for the scheduler + :param workers: Configuration of the default worker group + """ + + def __init__(self, scheduler: Scheduler, workers: WorkerGroup): + self._scheduler = scheduler + self._workers = workers + + @property + def scheduler(self) -> Scheduler: + """ + :return: Configuration for the scheduler pod + """ + return self._scheduler + + @property + def workers(self) -> WorkerGroup: + """ + :return: Configuration of the default worker group + """ + return self._workers + + def to_flyte_idl(self) -> dask_task.DaskJob: + """ + :return: The dask job serialized to protobuf + """ + return dask_task.DaskJob( + scheduler=self.scheduler.to_flyte_idl(), + workers=self.workers.to_flyte_idl(), + ) diff --git a/flytekit/plugins/flytekit-dask/flytekitplugins/dask/task.py b/flytekit/plugins/flytekit-dask/flytekitplugins/dask/task.py new file mode 100644 index 0000000000..b5b5a42008 --- /dev/null +++ b/flytekit/plugins/flytekit-dask/flytekitplugins/dask/task.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Optional + +from flytekitplugins.dask import models +from google.protobuf.json_format import MessageToDict + +from flytekit import PythonFunctionTask, Resources +from flytekit.configuration import SerializationSettings +from flytekit.core.resources import convert_resources_to_resource_model +from flytekit.core.task import TaskPlugins + + +@dataclass +class Scheduler: + """ + Configuration for the scheduler pod + + :param image: Custom image to use. If ``None``, will use the same image the task was registered with. Optional, + defaults to ``None``. The image must have ``dask[distributed]`` installed and should have the same Python + environment as the rest of the cluster (job runner pod + worker pods). + :param requests: Resources to request for the scheduler pod. If ``None``, the requests passed into the task will be + used. Optional, defaults to ``None``. + :param limits: Resource limits for the scheduler pod. If ``None``, the limits passed into the task will be used. + Optional, defaults to ``None``. + """ + + image: Optional[str] = None + requests: Optional[Resources] = None + limits: Optional[Resources] = None + + +@dataclass +class WorkerGroup: + """ + Configuration for a group of dask worker pods + + :param number_of_workers: Number of workers to use. Optional, defaults to 1. + :param image: Custom image to use. If ``None``, will use the same image the task was registered with. Optional, + defaults to ``None``. The image must have ``dask[distributed]`` installed. The provided image should have the + same Python environment as the job runner/driver as well as the scheduler. + :param requests: Resources to request for the worker pods. If ``None``, the requests passed into the task will be + used. Optional, defaults to ``None``. + :param limits: Resource limits for the worker pods. If ``None``, the limits passed into the task will be used. + Optional, defaults to ``None``. + """ + + number_of_workers: Optional[int] = 1 + image: Optional[str] = None + requests: Optional[Resources] = None + limits: Optional[Resources] = None + + +@dataclass +class Dask: + """ + Configuration for the dask task + + :param scheduler: Configuration for the scheduler pod. Optional, defaults to ``Scheduler()``. + :param workers: Configuration for the pods of the default worker group. Optional, defaults to ``WorkerGroup()``. + """ + + scheduler: Scheduler = field(default_factory=lambda: Scheduler()) + workers: WorkerGroup = field(default_factory=lambda: WorkerGroup()) + + +class DaskTask(PythonFunctionTask[Dask]): + """ + Actual Plugin that transforms the local python code for execution within a dask cluster + """ + + _DASK_TASK_TYPE = "dask" + + def __init__(self, task_config: Dask, task_function: Callable, **kwargs): + super(DaskTask, self).__init__( + task_config=task_config, + task_type=self._DASK_TASK_TYPE, + task_function=task_function, + **kwargs, + ) + + def get_custom(self, settings: SerializationSettings) -> Optional[Dict[str, Any]]: + """ + Serialize the `dask` task config into a dict. + + :param settings: Current serialization settings + :return: Dictionary representation of the dask task config. + """ + scheduler = models.Scheduler( + image=self.task_config.scheduler.image, + resources=convert_resources_to_resource_model( + requests=self.task_config.scheduler.requests, + limits=self.task_config.scheduler.limits, + ), + ) + workers = models.WorkerGroup( + number_of_workers=self.task_config.workers.number_of_workers, + image=self.task_config.workers.image, + resources=convert_resources_to_resource_model( + requests=self.task_config.workers.requests, + limits=self.task_config.workers.limits, + ), + ) + job = models.DaskJob(scheduler=scheduler, workers=workers) + return MessageToDict(job.to_flyte_idl()) + + +# Inject the `dask` plugin into flytekits dynamic plugin loading system +TaskPlugins.register_pythontask_plugin(Dask, DaskTask) diff --git a/flytekit/plugins/flytekit-dask/setup.py b/flytekit/plugins/flytekit-dask/setup.py new file mode 100644 index 0000000000..440d7b47db --- /dev/null +++ b/flytekit/plugins/flytekit-dask/setup.py @@ -0,0 +1,42 @@ +from setuptools import setup + +PLUGIN_NAME = "dask" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = [ + "flyteidl>=1.3.2", + "flytekit>=1.3.0b2,<2.0.0", + "dask[distributed]>=2022.10.2", +] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="Dask plugin for flytekit", + url="https://github.com/flyteorg/flytekit/tree/master/plugins/flytekit-dask", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}"], + install_requires=plugin_requires, + license="apache2", + python_requires=">=3.8", # dask requires >= 3.8 + classifiers=[ + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/flytekit/plugins/flytekit-dask/tests/__init__.py b/flytekit/plugins/flytekit-dask/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-dask/tests/test_models.py b/flytekit/plugins/flytekit-dask/tests/test_models.py new file mode 100644 index 0000000000..801a110fb1 --- /dev/null +++ b/flytekit/plugins/flytekit-dask/tests/test_models.py @@ -0,0 +1,96 @@ +import pytest +from flytekitplugins.dask import models + +from flytekit.models import task as _task + + +@pytest.fixture +def image() -> str: + return "foo:latest" + + +@pytest.fixture +def resources() -> _task.Resources: + return _task.Resources( + requests=[ + _task.Resources.ResourceEntry(name=_task.Resources.ResourceName.CPU, value="3"), + ], + limits=[], + ) + + +@pytest.fixture +def default_resources() -> _task.Resources: + return _task.Resources(requests=[], limits=[]) + + +@pytest.fixture +def scheduler(image: str, resources: _task.Resources) -> models.Scheduler: + return models.Scheduler(image=image, resources=resources) + + +@pytest.fixture +def workers(image: str, resources: _task.Resources) -> models.WorkerGroup: + return models.WorkerGroup(number_of_workers=123, image=image, resources=resources) + + +def test_create_scheduler_to_flyte_idl_no_optional(image: str, resources: _task.Resources): + scheduler = models.Scheduler(image=image, resources=resources) + idl_object = scheduler.to_flyte_idl() + assert idl_object.image == image + assert idl_object.resources == resources.to_flyte_idl() + + +def test_create_scheduler_to_flyte_idl_all_optional(default_resources: _task.Resources): + scheduler = models.Scheduler(image=None, resources=None) + idl_object = scheduler.to_flyte_idl() + assert idl_object.image == "" + assert idl_object.resources == default_resources.to_flyte_idl() + + +def test_create_scheduler_spec_property_access(image: str, resources: _task.Resources): + scheduler = models.Scheduler(image=image, resources=resources) + assert scheduler.image == image + assert scheduler.resources == resources + + +def test_worker_group_to_flyte_idl_no_optional(image: str, resources: _task.Resources): + n_workers = 1234 + worker_group = models.WorkerGroup(number_of_workers=n_workers, image=image, resources=resources) + idl_object = worker_group.to_flyte_idl() + assert idl_object.number_of_workers == n_workers + assert idl_object.image == image + assert idl_object.resources == resources.to_flyte_idl() + + +def test_worker_group_to_flyte_idl_all_optional(default_resources: _task.Resources): + worker_group = models.WorkerGroup(number_of_workers=1, image=None, resources=None) + idl_object = worker_group.to_flyte_idl() + assert idl_object.image == "" + assert idl_object.resources == default_resources.to_flyte_idl() + + +def test_worker_group_property_access(image: str, resources: _task.Resources): + n_workers = 1234 + worker_group = models.WorkerGroup(number_of_workers=n_workers, image=image, resources=resources) + assert worker_group.image == image + assert worker_group.number_of_workers == n_workers + assert worker_group.resources == resources + + +def test_worker_group_fails_for_less_than_one_worker(): + with pytest.raises(ValueError, match=r"Each worker group needs to"): + models.WorkerGroup(number_of_workers=0, image=None, resources=None) + + +def test_dask_job_to_flyte_idl_no_optional(scheduler: models.Scheduler, workers: models.WorkerGroup): + job = models.DaskJob(scheduler=scheduler, workers=workers) + idl_object = job.to_flyte_idl() + assert idl_object.scheduler == scheduler.to_flyte_idl() + assert idl_object.workers == workers.to_flyte_idl() + + +def test_dask_job_property_access(scheduler: models.Scheduler, workers: models.WorkerGroup): + job = models.DaskJob(scheduler=scheduler, workers=workers) + assert job.scheduler == scheduler + assert job.workers == workers diff --git a/flytekit/plugins/flytekit-dask/tests/test_task.py b/flytekit/plugins/flytekit-dask/tests/test_task.py new file mode 100644 index 0000000000..76dbf9d048 --- /dev/null +++ b/flytekit/plugins/flytekit-dask/tests/test_task.py @@ -0,0 +1,86 @@ +import pytest +from flytekitplugins.dask import Dask, Scheduler, WorkerGroup + +from flytekit import PythonFunctionTask, Resources, task +from flytekit.configuration import Image, ImageConfig, SerializationSettings + + +@pytest.fixture +def serialization_settings() -> SerializationSettings: + 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]), + ) + return settings + + +def test_dask_task_with_default_config(serialization_settings: SerializationSettings): + task_config = Dask() + + @task(task_config=task_config) + def dask_task(): + pass + + # Helping type completion in PyCharm + dask_task: PythonFunctionTask[Dask] + + assert dask_task.task_config == task_config + assert dask_task.task_type == "dask" + + expected_dict = { + "scheduler": { + "resources": {}, + }, + "workers": { + "numberOfWorkers": 1, + "resources": {}, + }, + } + assert dask_task.get_custom(serialization_settings) == expected_dict + + +def test_dask_task_get_custom(serialization_settings: SerializationSettings): + task_config = Dask( + scheduler=Scheduler( + image="scheduler:latest", + requests=Resources(cpu="1"), + limits=Resources(cpu="2"), + ), + workers=WorkerGroup( + number_of_workers=123, + image="dask_cluster:latest", + requests=Resources(cpu="3"), + limits=Resources(cpu="4"), + ), + ) + + @task(task_config=task_config) + def dask_task(): + pass + + # Helping type completion in PyCharm + dask_task: PythonFunctionTask[Dask] + + expected_custom_dict = { + "scheduler": { + "image": "scheduler:latest", + "resources": { + "requests": [{"name": "CPU", "value": "1"}], + "limits": [{"name": "CPU", "value": "2"}], + }, + }, + "workers": { + "numberOfWorkers": 123, + "image": "dask_cluster:latest", + "resources": { + "requests": [{"name": "CPU", "value": "3"}], + "limits": [{"name": "CPU", "value": "4"}], + }, + }, + } + custom_dict = dask_task.get_custom(serialization_settings) + assert custom_dict == expected_custom_dict diff --git a/flytekit/plugins/flytekit-data-fsspec/README.md b/flytekit/plugins/flytekit-data-fsspec/README.md new file mode 100644 index 0000000000..e7962f1b3b --- /dev/null +++ b/flytekit/plugins/flytekit-data-fsspec/README.md @@ -0,0 +1,5 @@ +# fsspec data plugin for Flytekit — Experimental + +This plugin provides an implementation of the data persistence layer in Flytekit that uses fsspec. Once this plugin +is installed, it overrides all default implementations of the data plugins and provides the ones supported by fsspec. This plugin +will only install the fsspec core. To install all fsspec plugins, please follow the [fsspec documentation](https://filesystem-spec.readthedocs.io/en/latest/). diff --git a/flytekit/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py b/flytekit/plugins/flytekit-data-fsspec/flytekitplugins/fsspec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-data-fsspec/setup.py b/flytekit/plugins/flytekit-data-fsspec/setup.py new file mode 100644 index 0000000000..0ceae3ac1b --- /dev/null +++ b/flytekit/plugins/flytekit-data-fsspec/setup.py @@ -0,0 +1,44 @@ +from setuptools import setup + +PLUGIN_NAME = "fsspec" + +microlib_name = f"flytekitplugins-data-{PLUGIN_NAME}" + +plugin_requires = [] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + 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", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}"], + install_requires=plugin_requires, + extras_require={ + # https://github.com/fsspec/filesystem_spec/blob/master/setup.py#L36 + "abfs": [], + "aws": [], + "gcp": [], + }, + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/flytekit/plugins/flytekit-data-fsspec/tests/__init__.py b/flytekit/plugins/flytekit-data-fsspec/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-data-fsspec/tests/test_placeholder.py b/flytekit/plugins/flytekit-data-fsspec/tests/test_placeholder.py new file mode 100644 index 0000000000..eb6dc82a34 --- /dev/null +++ b/flytekit/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/flytekit/plugins/flytekit-dbt/README.md b/flytekit/plugins/flytekit-dbt/README.md new file mode 100644 index 0000000000..bfea79849e --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/README.md @@ -0,0 +1,15 @@ +# Flytekit dbt plugin + +Flytekit plugin for performing DBT tasks. Currently it supports `dbt run` , `dbt test`, `dbt source freshness` tasks. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-dbt +``` + +_Example coming soon!_ + +## Contributors + +- [Gojek](https://www.gojek.io/) diff --git a/flytekit/plugins/flytekit-dbt/dev-requirements.in b/flytekit/plugins/flytekit-dbt/dev-requirements.in new file mode 100644 index 0000000000..6a7786f5fa --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/dev-requirements.in @@ -0,0 +1,2 @@ +dbt-sqlite==1.4.0 +dbt-core>=1.0.0,<1.4.6 diff --git a/flytekit/plugins/flytekit-dbt/dev-requirements.txt b/flytekit/plugins/flytekit-dbt/dev-requirements.txt new file mode 100644 index 0000000000..da14dc11a6 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/dev-requirements.txt @@ -0,0 +1,124 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile dev-requirements.in +# +agate==1.7.0 + # via dbt-core +attrs==23.1.0 + # via jsonschema +babel==2.13.1 + # via agate +betterproto==1.2.5 + # via dbt-core +certifi==2023.7.22 + # via requests +cffi==1.16.0 + # via dbt-core +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via dbt-core +colorama==0.4.6 + # via dbt-core +dbt-core==1.4.5 + # via + # -r dev-requirements.in + # dbt-sqlite +dbt-extractor==0.4.1 + # via dbt-core +dbt-sqlite==1.4.0 + # via -r dev-requirements.in +future==0.18.3 + # via parsedatetime +grpclib==0.4.6 + # via betterproto +h2==4.1.0 + # via grpclib +hologram==0.0.15 + # via dbt-core +hpack==4.0.0 + # via h2 +hyperframe==6.0.1 + # via h2 +idna==3.4 + # via + # dbt-core + # requests +isodate==0.6.1 + # via + # agate + # dbt-core +jinja2==3.1.2 + # via dbt-core +jsonschema==3.2.0 + # via hologram +leather==0.3.4 + # via agate +logbook==1.5.3 + # via dbt-core +markupsafe==2.1.3 + # via + # jinja2 + # werkzeug +mashumaro[msgpack]==3.3.1 + # via + # dbt-core + # mashumaro +minimal-snowplow-tracker==0.0.2 + # via dbt-core +msgpack==1.0.7 + # via mashumaro +multidict==6.0.4 + # via grpclib +networkx==2.8.8 + # via dbt-core +packaging==23.2 + # via dbt-core +parsedatetime==2.4 + # via agate +pathspec==0.10.3 + # via dbt-core +pycparser==2.21 + # via cffi +pyrsistent==0.20.0 + # via jsonschema +python-dateutil==2.8.2 + # via hologram +python-slugify==8.0.1 + # via agate +pytimeparse==1.1.8 + # via agate +pytz==2023.3.post1 + # via dbt-core +pyyaml==6.0.1 + # via dbt-core +requests==2.31.0 + # via + # dbt-core + # minimal-snowplow-tracker +six==1.16.0 + # via + # isodate + # jsonschema + # leather + # minimal-snowplow-tracker + # python-dateutil +sqlparse==0.4.4 + # via dbt-core +stringcase==1.2.0 + # via betterproto +text-unidecode==1.3 + # via python-slugify +typing-extensions==4.8.0 + # via + # dbt-core + # mashumaro +urllib3==2.0.7 + # via requests +werkzeug==2.3.8 + # via dbt-core + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/__init__.py b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/__init__.py new file mode 100644 index 0000000000..437607657f --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/__init__.py @@ -0,0 +1,21 @@ +""" +.. currentmodule:: flytekitplugins.dbt + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + DBTRun + DBTTest + BaseDBTInput + BaseDBTOutput + DBTRunInput + DBTRunOutput + DBTTestInput + DBTTestOutput +""" + +from .schema import BaseDBTInput, BaseDBTOutput, DBTRunInput, DBTRunOutput, DBTTestInput, DBTTestOutput +from .task import DBTRun, DBTTest diff --git a/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/error.py b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/error.py new file mode 100644 index 0000000000..a617b6e505 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/error.py @@ -0,0 +1,49 @@ +from typing import List + + +class DBTHandledError(Exception): + """ + DBTHandledError wraps error logs and message from command execution that returns ``exit code 1``. + + Parameters + ---------- + message : str + Error message. + logs : list of str + Logs produced by the command execution. + + Attributes + ---------- + message : str + Error message. + logs : list of str + Logs produced by the command execution. + """ + + def __init__(self, message: str, logs: List[str]): + self.logs = logs + self.message = message + + +class DBTUnhandledError(Exception): + """ + DBTUnhandledError wraps error logs and message from command execution that returns ``exit code 2``. + + Parameters + ---------- + message : str + Error message. + logs : list of str + Logs produced by the command execution. + + Attributes + ---------- + message : str + Error message. + logs : list of str + Logs produced by the command execution. + """ + + def __init__(self, message: str, logs: List[str]): + self.logs = logs + self.message = message diff --git a/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/schema.py b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/schema.py new file mode 100644 index 0000000000..6163e440b1 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/schema.py @@ -0,0 +1,250 @@ +import json +from dataclasses import dataclass +from typing import List, Optional + +from dataclasses_json import DataClassJsonMixin + + +@dataclass +class BaseDBTInput(DataClassJsonMixin): + """ + Base class for DBT Task Input. + + Attributes + ---------- + project_dir : str + Path to directory containing the DBT ``dbt_project.yml``. + profiles_dir : str + Path to directory containing the DBT ``profiles.yml``. + profile : str + Profile name to be used for the DBT task. It will override value in ``dbt_project.yml``. + target : str + Target to load for the given profile (default=None). + output_path : str + Path to directory where compiled files (e.g. models) will be written when running the task (default=target). + ignore_handled_error : bool + Ignore handled error (exit code = 1) returned by DBT, see https://docs.getdbt.com/reference/exit-codes (default=False). + flags : dict + Dictionary containing CLI flags to be added to the ``dbt run`` command (default=False). + """ + + project_dir: str + profiles_dir: str + profile: str + target: str = None + output_path: str = "target" + ignore_handled_error: bool = False + flags: dict = None + + def to_args(self) -> List[str]: + """ + Convert the instance of BaseDBTInput into list of arguments. + + Returns + ------- + List[str] + List of arguments. + """ + + args = [] + args += ["--project-dir", self.project_dir] + args += ["--profiles-dir", self.profiles_dir] + args += ["--profile", self.profile] + if self.target is not None: + args += ["--target", self.target] + + if self.flags is not None: + for flag, value in self.flags.items(): + if not value: + continue + + args.append(f"--{flag}") + if isinstance(value, bool): + continue + + if isinstance(value, list): + args += value + continue + + if isinstance(value, dict): + args.append(json.dumps(value)) + continue + + args.append(str(value)) + + return args + + +@dataclass +class BaseDBTOutput(DataClassJsonMixin): + """ + Base class for output of DBT task. + + Attributes + ---------- + command : str + Complete CLI command and flags that was executed by DBT Task. + exit_code : int + Exit code returned by DBT CLI. + """ + + command: str + exit_code: int + + +@dataclass +class DBTRunInput(BaseDBTInput): + """ + Input to DBT Run task. + + Attributes + ---------- + select : List[str] + List of model to be executed (default=None). + exclude : List[str] + List of model to be excluded (default=None). + """ + + select: Optional[List[str]] = None + exclude: Optional[List[str]] = None + + def to_args(self) -> List[str]: + """ + Convert the instance of BaseDBTInput into list of arguments. + + Returns + ------- + List[str] + List of arguments. + """ + + args = BaseDBTInput.to_args(self) + if self.select is not None: + args += ["--select"] + self.select + + if self.exclude is not None: + args += ["--exclude"] + self.exclude + + return args + + +@dataclass +class DBTRunOutput(BaseDBTOutput): + """ + Output of DBT run task. + + Attributes + ---------- + raw_run_result : str + Raw value of DBT's ``run_result.json``. + raw_manifest : str + Raw value of DBT's ``manifest.json``. + """ + + raw_run_result: str + raw_manifest: str + + +@dataclass +class DBTTestInput(BaseDBTInput): + """ + Input to DBT Test task. + + Attributes + ---------- + select : List[str] + List of model to be executed (default : None). + exclude : List[str] + List of model to be excluded (default : None). + """ + + select: Optional[List[str]] = None + exclude: Optional[List[str]] = None + + def to_args(self) -> List[str]: + """ + Convert the instance of DBTTestInput into list of arguments. + + Returns + ------- + List[str] + List of arguments. + """ + + args = BaseDBTInput.to_args(self) + + if self.select is not None: + args += ["--select"] + self.select + + if self.exclude is not None: + args += ["--exclude"] + self.exclude + + return args + + +@dataclass +class DBTTestOutput(BaseDBTOutput): + """ + Output of DBT test task. + + Attributes + ---------- + raw_run_result : str + Raw value of DBT's ``run_result.json``. + raw_manifest : str + Raw value of DBT's ``manifest.json``. + """ + + raw_run_result: str + raw_manifest: str + + +@dataclass +class DBTFreshnessInput(BaseDBTInput): + """ + Input to DBT Freshness task. + + Attributes + ---------- + select : List[str] + List of model to be executed (default : None). + exclude : List[str] + List of model to be excluded (default : None). + """ + + select: Optional[List[str]] = None + exclude: Optional[List[str]] = None + + def to_args(self) -> List[str]: + """ + Convert the instance of DBTFreshnessInput into list of arguments. + + Returns + ------- + List[str] + List of arguments. + """ + + args = BaseDBTInput.to_args(self) + + if self.select is not None: + args += ["--select"] + self.select + + if self.exclude is not None: + args += ["--exclude"] + self.exclude + + return args + + +@dataclass +class DBTFreshnessOutput(BaseDBTOutput): + """ + Output of DBT Freshness task. + + Attributes + ---------- + raw_sources : str + Raw value of DBT's ``sources.json``. + """ + + raw_sources: str diff --git a/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/task.py b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/task.py new file mode 100644 index 0000000000..4e7c721837 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/task.py @@ -0,0 +1,361 @@ +import os + +from flytekitplugins.dbt.error import DBTHandledError, DBTUnhandledError +from flytekitplugins.dbt.schema import ( + DBTFreshnessInput, + DBTFreshnessOutput, + DBTRunInput, + DBTRunOutput, + DBTTestInput, + DBTTestOutput, +) +from flytekitplugins.dbt.util import run_cli + +from flytekit import kwtypes +from flytekit.core.interface import Interface +from flytekit.core.python_function_task import PythonInstanceTask +from flytekit.loggers import logger + +SUCCESS = 0 +HANDLED_ERROR_CODE = 1 +UNHANDLED_ERROR_CODE = 2 + + +class DBTRun(PythonInstanceTask): + """ + Execute DBT Run CLI command. + + The task will execute ``dbt run`` CLI command in a subprocess. + Input from :class:`flytekitplugins.dbt.schema.DBTRunInput` will be converted into the corresponding CLI flags + and stored in :class:`flytekitplugins.dbt.schema.DBTRunOutput`'s command. + + Parameters + ---------- + name : str + Task name. + """ + + def __init__( + self, + name: str, + **kwargs, + ): + super(DBTRun, self).__init__( + task_type="dbt-run", + name=name, + task_config=None, + interface=Interface(inputs=kwtypes(input=DBTRunInput), outputs=kwtypes(output=DBTRunOutput)), + **kwargs, + ) + + def execute(self, **kwargs) -> DBTRunOutput: + """ + This method will be invoked to execute the task. + + Example + ------- + :: + + dbt_run_task = DBTRun(name="test-task") + + @workflow + def my_workflow() -> DBTRunOutput: + return dbt_run_task( + input=DBTRunInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + ) + ) + + + Parameters + ---------- + input : DBTRunInput + DBT run input. + + Returns + ------- + DBTRunOutput + DBT run output. + + Raises + ------ + DBTHandledError + If the ``dbt run`` command returns ``exit code 1``. + DBTUnhandledError + If the ``dbt run`` command returns ``exit code 2``. + """ + + task_input: DBTRunInput = kwargs["input"] + + args = task_input.to_args() + cmd = ["dbt", "--log-format", "json", "run"] + args + full_command = " ".join(cmd) + + logger.info(f"Executing command: {full_command}") + exit_code, logs = run_cli(cmd) + logger.info(f"dbt exited with return code {exit_code}") + + if exit_code == HANDLED_ERROR_CODE and not task_input.ignore_handled_error: + raise DBTHandledError(f"handled error while executing {full_command}", logs) + + if exit_code == UNHANDLED_ERROR_CODE: + raise DBTUnhandledError(f"unhandled error while executing {full_command}", logs) + + output_dir = os.path.join(task_input.project_dir, task_input.output_path) + run_result_path = os.path.join(output_dir, "run_results.json") + with open(run_result_path) as file: + run_result = file.read() + + # read manifest.json + manifest_path = os.path.join(output_dir, "manifest.json") + with open(manifest_path) as file: + manifest = file.read() + + return DBTRunOutput( + command=full_command, + exit_code=exit_code, + raw_run_result=run_result, + raw_manifest=manifest, + ) + + +class DBTTest(PythonInstanceTask): + """Execute DBT Test CLI command + + The task will execute ``dbt test`` CLI command in a subprocess. + Input from :class:`flytekitplugins.dbt.schema.DBTTestInput` will be converted into the corresponding CLI flags + and stored in :class:`flytekitplugins.dbt.schema.DBTTestOutput`'s command. + + Parameters + ---------- + name : str + Task name. + """ + + def __init__( + self, + name: str, + **kwargs, + ): + super(DBTTest, self).__init__( + task_type="dbt-test", + name=name, + task_config=None, + interface=Interface( + inputs={ + "input": DBTTestInput, + }, + outputs={"output": DBTTestOutput}, + ), + **kwargs, + ) + + def execute(self, **kwargs) -> DBTTestOutput: + """ + This method will be invoked to execute the task. + + Example + ------- + :: + + dbt_test_task = DBTTest(name="test-task") + + @workflow + def my_workflow() -> DBTTestOutput: + # run all models + dbt_test_task( + input=DBTTestInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + ) + ) + + # run singular test only + dbt_test_task( + input=DBTTestInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + select=["test_type:singular"], + ) + ) + + # run both singular and generic test + return dbt_test_task( + input=DBTTestInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + select=["test_type:singular", "test_type:generic"], + ) + ) + + + Parameters + ---------- + input : DBTTestInput + DBT test input + + Returns + ------- + DBTTestOutput + DBT test output + + Raises + ------ + DBTHandledError + If the ``dbt test`` command returns ``exit code 1``. + DBTUnhandledError + If the ``dbt test`` command returns ``exit code 2``. + """ + + task_input: DBTTestInput = kwargs["input"] + + args = task_input.to_args() + cmd = ["dbt", "--log-format", "json", "test"] + args + full_command = " ".join(cmd) + + logger.info(f"Executing command: {full_command}") + exit_code, logs = run_cli(cmd) + logger.info(f"dbt exited with return code {exit_code}") + + if exit_code == HANDLED_ERROR_CODE and not task_input.ignore_handled_error: + raise DBTHandledError(f"handled error while executing {full_command}", logs) + + if exit_code == UNHANDLED_ERROR_CODE: + raise DBTUnhandledError(f"unhandled error while executing {full_command}", logs) + + output_dir = os.path.join(task_input.project_dir, task_input.output_path) + run_result_path = os.path.join(output_dir, "run_results.json") + with open(run_result_path) as file: + run_result = file.read() + + # read manifest.json + manifest_path = os.path.join(output_dir, "manifest.json") + with open(manifest_path) as file: + manifest = file.read() + + return DBTTestOutput( + command=full_command, + exit_code=exit_code, + raw_run_result=run_result, + raw_manifest=manifest, + ) + + +class DBTFreshness(PythonInstanceTask): + """Execute DBT Freshness CLI command + + The task will execute ``dbt freshness`` CLI command in a subprocess. + Input from :class:`flytekitplugins.dbt.schema.DBTFreshnessInput` will be converted into the corresponding CLI flags + and stored in :class:`flytekitplugins.dbt.schema.DBTFreshnessOutput`'s command. + + Parameters + ---------- + name : str + Task name. + """ + + def __init__( + self, + name: str, + **kwargs, + ): + super(DBTFreshness, self).__init__( + task_type="dbt-freshness", + name=name, + task_config=None, + interface=Interface( + inputs={ + "input": DBTFreshnessInput, + }, + outputs={"output": DBTFreshnessOutput}, + ), + **kwargs, + ) + + def execute(self, **kwargs) -> DBTFreshnessOutput: + """ + This method will be invoked to execute the task. + + Example + ------- + :: + + dbt_freshness_task = DBTFreshness(name="freshness-task") + + @workflow + def my_workflow() -> DBTFreshnessOutput: + # run all models + dbt_freshness_task( + input=DBTFreshnessInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + ) + ) + + # run singular freshness only + dbt_freshness_task( + input=DBTFreshnessInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + select=["test_type:singular"], + ) + ) + + # run both singular and generic freshness + return dbt_freshness_task( + input=DBTFreshnessInput( + project_dir="tests/jaffle_shop", + profiles_dir="tests/jaffle_shop/profiles", + profile="jaffle_shop", + select=["test_type:singular", "test_type:generic"], + ) + ) + + + Parameters + ---------- + input : DBTFreshnessInput + DBT freshness input + + Returns + ------- + DBTFreshnessOutput + DBT freshness output + + Raises + ------ + DBTHandledError + If the ``dbt source freshness`` command returns ``exit code 1``. + DBTUnhandledError + If the ``dbt source freshness`` command returns ``exit code 2``. + """ + + task_input: DBTFreshnessInput = kwargs["input"] + + args = task_input.to_args() + cmd = ["dbt", "--log-format", "json", "source", "freshness"] + args + full_command = " ".join(cmd) + + logger.info(f"Executing command: {full_command}") + exit_code, logs = run_cli(cmd) + logger.info(f"dbt exited with return code {exit_code}") + + if exit_code == HANDLED_ERROR_CODE and not task_input.ignore_handled_error: + raise DBTHandledError(f"handled error while executing {full_command}", logs) + + if exit_code == UNHANDLED_ERROR_CODE: + raise DBTUnhandledError(f"unhandled error while executing {full_command}", logs) + + output_dir = os.path.join(task_input.project_dir, task_input.output_path) + sources_path = os.path.join(output_dir, "sources.json") + with open(sources_path) as file: + sources = file.read() + + return DBTFreshnessOutput(command=full_command, exit_code=exit_code, raw_sources=sources) diff --git a/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/util.py b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/util.py new file mode 100644 index 0000000000..c127c9279c --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/flytekitplugins/dbt/util.py @@ -0,0 +1,40 @@ +import json +import subprocess +from typing import List + +from flytekit.loggers import logger + + +def run_cli(cmd: List[str]) -> (int, List[str]): + """ + Execute a CLI command in a subprocess + + Parameters + ---------- + cmd : list of str + Command to be executed. + + Returns + ------- + int + Command's exit code. + list of str + Logs produced by the command execution. + """ + + logs = [] + process = subprocess.Popen(cmd, stdout=subprocess.PIPE) + for raw_line in process.stdout or []: + line = raw_line.decode("utf-8") + try: + json_line = json.loads(line) + except json.JSONDecodeError: + logger.info(line.rstrip()) + else: + logs.append(json_line) + # TODO: pluck `levelname` from json_line and choose appropriate level to use + # in flytekit logger instead of defaulting to `info` + logger.info(line.rstrip()) + + process.wait() + return process.returncode, logs diff --git a/flytekit/plugins/flytekit-dbt/setup.py b/flytekit/plugins/flytekit-dbt/setup.py new file mode 100644 index 0000000000..943386bed1 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/setup.py @@ -0,0 +1,42 @@ +from setuptools import setup + +PLUGIN_NAME = "dbt" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = [ + "flytekit>=1.3.0b2,<2.0.0", + "dbt-core>=1.0.0", +] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="DBT Plugin for Flytekit", + url="https://github.com/flyteorg/flytekit/tree/master/plugins/flytekit-dbt", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + 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", + ], +) diff --git a/flytekit/plugins/flytekit-dbt/tests/__init__.py b/flytekit/plugins/flytekit-dbt/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-dbt/tests/test_schema.py b/flytekit/plugins/flytekit-dbt/tests/test_schema.py new file mode 100644 index 0000000000..6555b209d5 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/test_schema.py @@ -0,0 +1,334 @@ +import shlex + +import pytest +from flytekitplugins.dbt.schema import BaseDBTInput, DBTFreshnessInput, DBTRunInput, DBTTestInput + +project_dir = "." +profiles_dir = "profiles" +profile_name = "development" + + +class TestBaseDBTInput: + @pytest.mark.parametrize( + "task_input,expected", + [ + ( + BaseDBTInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name}", + ), + ( + BaseDBTInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + target="production", + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --target production", + ), + ( + BaseDBTInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + flags={"vars": {"var1": "val1", "var2": 2}}, + ), + f"""--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --vars '{{"var1": "val1", "var2": 2}}'""", + ), + ( + BaseDBTInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + flags={"bool-flag": True}, + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --bool-flag", + ), + ( + BaseDBTInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + flags={"list-flag": ["a", "b", "c"]}, + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --list-flag a b c", + ), + ], + ) + def test_to_args(self, task_input, expected): + assert task_input.to_args() == shlex.split(expected) + + +class TestDBRunTInput: + @pytest.mark.parametrize( + "task_input,expected", + [ + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name}", + ), + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["model_a", "model_b"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select model_a model_b", + ), + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["tag:nightly", "my_model", "finance.base.*"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select tag:nightly my_model finance.base.*", + ), + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["path:marts/finance,tag:nightly,config.materialized:table"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select path:marts/finance,tag:nightly,config.materialized:table", + ), + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["model_a", "model_b"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude model_a model_b", + ), + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["tag:nightly", "my_model", "finance.base.*"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude tag:nightly my_model finance.base.*", + ), + ( + DBTRunInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["path:marts/finance,tag:nightly,config.materialized:table"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude path:marts/finance,tag:nightly,config.materialized:table", + ), + ], + ) + def test_to_args(self, task_input, expected): + assert task_input.to_args() == shlex.split(expected) + + +class TestDBTestInput: + @pytest.mark.parametrize( + "task_input,expected", + [ + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name}", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["test_type:singular"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select test_type:singular", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["model_a", "model_b"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select model_a model_b", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["tag:nightly", "my_model", "finance.base.*"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select tag:nightly my_model finance.base.*", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["tag:nightly", "my_model", "finance.base.*", "test_type:singular"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select tag:nightly my_model finance.base.* test_type:singular", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["path:marts/finance,tag:nightly,config.materialized:table,test_type:singular"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select path:marts/finance,tag:nightly,config.materialized:table,test_type:singular", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["model_a", "model_b"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude model_a model_b", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["tag:nightly", "my_model", "finance.base.*"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude tag:nightly my_model finance.base.*", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["path:marts/finance,tag:nightly,config.materialized:table"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude path:marts/finance,tag:nightly,config.materialized:table", + ), + ( + DBTTestInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["test_type:singular"], + exclude=["path:marts/finance,tag:nightly,config.materialized:table"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select test_type:singular --exclude path:marts/finance,tag:nightly,config.materialized:table", + ), + ], + ) + def test_to_args(self, task_input, expected): + assert task_input.to_args() == shlex.split(expected) + + +class TestDBFreshnessInput: + @pytest.mark.parametrize( + "task_input,expected", + [ + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name}", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["test_type:singular"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select test_type:singular", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["model_a", "model_b"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select model_a model_b", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["tag:nightly", "my_model", "finance.base.*"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select tag:nightly my_model finance.base.*", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["tag:nightly", "my_model", "finance.base.*", "test_type:singular"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select tag:nightly my_model finance.base.* test_type:singular", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["path:marts/finance,tag:nightly,config.materialized:table,test_type:singular"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select path:marts/finance,tag:nightly,config.materialized:table,test_type:singular", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["model_a", "model_b"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude model_a model_b", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["tag:nightly", "my_model", "finance.base.*"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude tag:nightly my_model finance.base.*", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + exclude=["path:marts/finance,tag:nightly,config.materialized:table"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --exclude path:marts/finance,tag:nightly,config.materialized:table", + ), + ( + DBTFreshnessInput( + project_dir=project_dir, + profiles_dir=profiles_dir, + profile=profile_name, + select=["test_type:singular"], + exclude=["path:marts/finance,tag:nightly,config.materialized:table"], + ), + f"--project-dir {project_dir} --profiles-dir {profiles_dir} --profile {profile_name} --select test_type:singular --exclude path:marts/finance,tag:nightly,config.materialized:table", + ), + ], + ) + def test_to_args(self, task_input, expected): + assert task_input.to_args() == shlex.split(expected) diff --git a/flytekit/plugins/flytekit-dbt/tests/test_task.py b/flytekit/plugins/flytekit-dbt/tests/test_task.py new file mode 100644 index 0000000000..cdad8d73f6 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/test_task.py @@ -0,0 +1,225 @@ +import os +import pathlib + +import pytest +from flytekitplugins.dbt.error import DBTUnhandledError +from flytekitplugins.dbt.schema import ( + DBTFreshnessInput, + DBTFreshnessOutput, + DBTRunInput, + DBTRunOutput, + DBTTestInput, + DBTTestOutput, +) +from flytekitplugins.dbt.task import DBTFreshness, DBTRun, DBTTest + +from flytekit import workflow +from flytekit.tools.subprocess import check_call + +DBT_PROJECT_DIR = str(pathlib.Path(os.path.dirname(os.path.realpath(__file__)), "testdata", "jaffle_shop")) +DBT_PROFILES_DIR = str(pathlib.Path(os.path.dirname(os.path.realpath(__file__)), "testdata", "profiles")) +DBT_PROFILE = "jaffle_shop" + + +@pytest.fixture(scope="module", autouse=True) +def prepare_db(): + # Ensure path to sqlite database file exists + dbs_path = pathlib.Path(DBT_PROJECT_DIR, "dbs") + dbs_path.mkdir(exist_ok=True, parents=True) + database_file = pathlib.Path(dbs_path, "database_name.db") + database_file.touch() + + # Seed the database + check_call( + [ + "dbt", + "--log-format", + "json", + "seed", + "--project-dir", + DBT_PROJECT_DIR, + "--profiles-dir", + DBT_PROFILES_DIR, + "--profile", + DBT_PROFILE, + ] + ) + + yield + + # Delete the database file + database_file.unlink() + + +class TestDBTRun: + def test_simple_task(self): + dbt_run_task = DBTRun( + name="test-task", + ) + + @workflow + def my_workflow() -> DBTRunOutput: + # run all models + return dbt_run_task( + input=DBTRunInput( + project_dir=DBT_PROJECT_DIR, + profiles_dir=DBT_PROFILES_DIR, + profile=DBT_PROFILE, + select=["tag:something"], + exclude=["tag:something-else"], + ) + ) + + result = my_workflow() + assert isinstance(result, DBTRunOutput) + + def test_incorrect_project_dir(self): + dbt_run_task = DBTRun( + name="test-task", + ) + + with pytest.raises(DBTUnhandledError): + dbt_run_task( + input=DBTRunInput( + project_dir=".", + profiles_dir=DBT_PROFILES_DIR, + profile=DBT_PROFILE, + ) + ) + + def test_task_output(self): + dbt_run_task = DBTRun( + name="test-task", + ) + + output = dbt_run_task.execute( + input=DBTRunInput(project_dir=DBT_PROJECT_DIR, profiles_dir=DBT_PROFILES_DIR, profile=DBT_PROFILE) + ) + + assert output.exit_code == 0 + assert ( + output.command + == f"dbt --log-format json run --project-dir {DBT_PROJECT_DIR} --profiles-dir {DBT_PROFILES_DIR} --profile {DBT_PROFILE}" + ) + + with open(f"{DBT_PROJECT_DIR}/target/run_results.json", "r") as fp: + exp_run_result = fp.read() + assert output.raw_run_result == exp_run_result + + with open(f"{DBT_PROJECT_DIR}/target/manifest.json", "r") as fp: + exp_manifest = fp.read() + assert output.raw_manifest == exp_manifest + + +class TestDBTTest: + def test_simple_task(self): + dbt_test_task = DBTTest( + name="test-task", + ) + + @workflow + def test_workflow() -> DBTTestOutput: + # run all tests + return dbt_test_task( + input=DBTTestInput( + project_dir=DBT_PROJECT_DIR, + profiles_dir=DBT_PROFILES_DIR, + profile=DBT_PROFILE, + ) + ) + + assert isinstance(test_workflow(), DBTTestOutput) + + def test_incorrect_project_dir(self): + dbt_test_task = DBTTest( + name="test-task", + ) + + with pytest.raises(DBTUnhandledError): + dbt_test_task( + input=DBTTestInput( + project_dir=".", + profiles_dir=DBT_PROFILES_DIR, + profile=DBT_PROFILE, + ) + ) + + def test_task_output(self): + dbt_test_task = DBTTest( + name="test-task", + ) + + output = dbt_test_task.execute( + input=DBTTestInput(project_dir=DBT_PROJECT_DIR, profiles_dir=DBT_PROFILES_DIR, profile=DBT_PROFILE) + ) + + assert output.exit_code == 0 + assert ( + output.command + == f"dbt --log-format json test --project-dir {DBT_PROJECT_DIR} --profiles-dir {DBT_PROFILES_DIR} --profile {DBT_PROFILE}" + ) + + with open(f"{DBT_PROJECT_DIR}/target/run_results.json", "r") as fp: + exp_run_result = fp.read() + assert output.raw_run_result == exp_run_result + + with open(f"{DBT_PROJECT_DIR}/target/manifest.json", "r") as fp: + exp_manifest = fp.read() + assert output.raw_manifest == exp_manifest + + +class TestDBTFreshness: + def test_simple_task(self): + dbt_freshness_task = DBTFreshness( + name="test-task", + ) + + @workflow + def my_workflow() -> DBTFreshnessOutput: + # run all models + return dbt_freshness_task( + input=DBTFreshnessInput( + project_dir=DBT_PROJECT_DIR, + profiles_dir=DBT_PROFILES_DIR, + profile=DBT_PROFILE, + select=["tag:something"], + exclude=["tag:something-else"], + ) + ) + + result = my_workflow() + assert isinstance(result, DBTFreshnessOutput) + + def test_incorrect_project_dir(self): + dbt_freshness_task = DBTFreshness( + name="test-task", + ) + + with pytest.raises(DBTUnhandledError): + dbt_freshness_task( + input=DBTFreshnessInput( + project_dir=".", + profiles_dir=DBT_PROFILES_DIR, + profile=DBT_PROFILE, + ) + ) + + def test_task_output(self): + dbt_freshness_task = DBTFreshness( + name="test-task", + ) + + output = dbt_freshness_task.execute( + input=DBTFreshnessInput(project_dir=DBT_PROJECT_DIR, profiles_dir=DBT_PROFILES_DIR, profile=DBT_PROFILE) + ) + + assert output.exit_code == 0 + assert ( + output.command + == f"dbt --log-format json source freshness --project-dir {DBT_PROJECT_DIR} --profiles-dir {DBT_PROFILES_DIR} --profile {DBT_PROFILE}" + ) + + with open(f"{DBT_PROJECT_DIR}/target/sources.json", "r") as fp: + exp_sources = fp.read() + + assert output.raw_sources == exp_sources diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/.gitignore b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/.gitignore new file mode 100644 index 0000000000..7164422079 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/.gitignore @@ -0,0 +1,5 @@ + +target/ +dbt_modules/ +logs/ +**/.DS_Store diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/LICENSE b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/LICENSE new file mode 100644 index 0000000000..8dada3edaf --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/README.md b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/README.md new file mode 100644 index 0000000000..cd94389ceb --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/README.md @@ -0,0 +1,94 @@ +## Testing dbt project: `jaffle_shop` + +`jaffle_shop` is a fictional ecommerce store. This dbt project transforms raw data from an app database into a customers and orders model ready for analytics. + +### What is this repo? + +What this repo _is_: + +- A self-contained playground dbt project, useful for testing out scripts, and communicating some of the core dbt concepts. + +What this repo _is not_: + +- A tutorial — check out the [Getting Started Tutorial](https://docs.getdbt.com/tutorial/setting-up) for that. Notably, this repo contains some anti-patterns to make it self-contained, namely the use of seeds instead of sources. +- A demonstration of best practices — check out the [dbt Learn Demo](https://github.com/dbt-labs/dbt-learn-demo) repo instead. We want to keep this project as simple as possible. As such, we chose not to implement: + - our standard file naming patterns (which make more sense on larger projects, rather than this five-model project) + - a pull request flow + - CI/CD integrations +- A demonstration of using dbt for a high-complex project, or a demo of advanced features (e.g. macros, packages, hooks, operations) — we're just trying to keep things simple here! + +### What's in this repo? + +This repo contains [seeds](https://docs.getdbt.com/docs/building-a-dbt-project/seeds) that includes some (fake) raw data from a fictional app. + +The raw data consists of customers, orders, and payments, with the following entity-relationship diagram: + +![Jaffle Shop ERD](./etc/jaffle_shop_erd.png) + +### Running this project + +To get up and running with this project: + +1. Install dbt using [these instructions](https://docs.getdbt.com/docs/installation). + +2. Clone this repository. + +3. Change into the `jaffle_shop` directory from the command line: + +```bash +$ cd jaffle_shop +``` + +4. Set up a profile called `jaffle_shop` to connect to a data warehouse by following [these instructions](https://docs.getdbt.com/docs/configure-your-profile). If you have access to a data warehouse, you can use those credentials – we recommend setting your [target schema](https://docs.getdbt.com/docs/configure-your-profile#section-populating-your-profile) to be a new schema (dbt will create the schema for you, as long as you have the right privileges). If you don't have access to an existing data warehouse, you can also setup a local postgres database and connect to it in your profile. + +5. Ensure your profile is setup correctly from the command line: + +```bash +$ dbt debug +``` + +6. Load the CSVs with the demo data set. This materializes the CSVs as tables in your target schema. Note that a typical dbt project **does not require this step** since dbt assumes your raw data is already in your warehouse. + +```bash +$ dbt seed +``` + +7. Run the models: + +```bash +$ dbt run +``` + +> **NOTE:** If this steps fails, it might mean that you need to make small changes to the SQL in the models folder to adjust for the flavor of SQL of your target database. Definitely consider this if you are using a community-contributed adapter. + +8. Test the output of the models: + +```bash +$ dbt test +``` + +9. Generate documentation for the project: + +```bash +$ dbt docs generate +``` + +10. View the documentation for the project: + +```bash +$ dbt docs serve +``` + +### What is a jaffle? + +A jaffle is a toasted sandwich with crimped, sealed edges. Invented in Bondi in 1949, the humble jaffle is an Australian classic. The sealed edges allow jaffle-eaters to enjoy liquid fillings inside the sandwich, which reach temperatures close to the core of the earth during cooking. Often consumed at home after a night out, the most classic filling is tinned spaghetti, while my personal favourite is leftover beef stew with melted cheese. + +--- + +For more information on dbt: + +- Read the [introduction to dbt](https://docs.getdbt.com/docs/introduction). +- Read the [dbt viewpoint](https://docs.getdbt.com/docs/about/viewpoint). +- Join the [dbt community](http://community.getdbt.com/). + +--- diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/dbt_project.yml b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/dbt_project.yml new file mode 100644 index 0000000000..acdce4c57c --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/dbt_project.yml @@ -0,0 +1,26 @@ +name: 'jaffle_shop' + +config-version: 2 +version: '0.1' + +profile: 'jaffle_shop' + +model-paths: ["models"] +seed-paths: ["seeds"] +test-paths: ["tests"] +analysis-paths: ["analysis"] +macro-paths: ["macros"] + +target-path: "target" +clean-targets: + - "target" + - "dbt_modules" + - "logs" + +require-dbt-version: [">=1.0.0", "<2.0.0"] + +models: + jaffle_shop: + materialized: table + staging: + materialized: view diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/dbdiagram_definition.txt b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/dbdiagram_definition.txt new file mode 100644 index 0000000000..3a6e12c079 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/dbdiagram_definition.txt @@ -0,0 +1,23 @@ +Table orders { + id int PK + user_id int + order_date date + status varchar +} + +Table payments { + id int + order_id int + payment_method int + amount int +} + +Table customers { + id int PK + first_name varchar + last_name varchar +} + +Ref: orders.user_id > customers.id + +Ref: payments.order_id > orders.id diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/jaffle_shop_erd.png b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/etc/jaffle_shop_erd.png new file mode 100644 index 0000000000000000000000000000000000000000..dd147390951dc301d25b90bdd29fa94ca4d4759e GIT binary patch literal 58179 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 literal 0 HcmV?d00001 diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/customers.sql b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/customers.sql new file mode 100644 index 0000000000..016a004fe5 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/customers.sql @@ -0,0 +1,69 @@ +with customers as ( + + select * from {{ ref('stg_customers') }} + +), + +orders as ( + + select * from {{ ref('stg_orders') }} + +), + +payments as ( + + select * from {{ ref('stg_payments') }} + +), + +customer_orders as ( + + select + customer_id, + + min(order_date) as first_order, + max(order_date) as most_recent_order, + count(order_id) as number_of_orders + from orders + + group by customer_id + +), + +customer_payments as ( + + select + orders.customer_id, + sum(amount) as total_amount + + from payments + + left join orders on + payments.order_id = orders.order_id + + group by orders.customer_id + +), + +final as ( + + select + customers.customer_id, + customers.first_name, + customers.last_name, + customer_orders.first_order, + customer_orders.most_recent_order, + customer_orders.number_of_orders, + customer_payments.total_amount as customer_lifetime_value + + from customers + + left join customer_orders + on customers.customer_id = customer_orders.customer_id + + left join customer_payments + on customers.customer_id = customer_payments.customer_id + +) + +select * from final diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/docs.md b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/docs.md new file mode 100644 index 0000000000..c6ae93be07 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/docs.md @@ -0,0 +1,14 @@ +{% docs orders_status %} + +Orders can be one of the following statuses: + +| status | description | +|----------------|------------------------------------------------------------------------------------------------------------------------| +| placed | The order has been placed but has not yet left the warehouse | +| shipped | The order has ben shipped to the customer and is currently in transit | +| completed | The order has been received by the customer | +| return_pending | The customer has indicated that they would like to return the order, but it has not yet been received at the warehouse | +| returned | The order has been returned by the customer and received at the warehouse | + + +{% enddocs %} diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/orders.sql b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/orders.sql new file mode 100644 index 0000000000..cbb2934911 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/orders.sql @@ -0,0 +1,56 @@ +{% set payment_methods = ['credit_card', 'coupon', 'bank_transfer', 'gift_card'] %} + +with orders as ( + + select * from {{ ref('stg_orders') }} + +), + +payments as ( + + select * from {{ ref('stg_payments') }} + +), + +order_payments as ( + + select + order_id, + + {% for payment_method in payment_methods -%} + sum(case when payment_method = '{{ payment_method }}' then amount else 0 end) as {{ payment_method }}_amount, + {% endfor -%} + + sum(amount) as total_amount + + from payments + + group by order_id + +), + +final as ( + + select + orders.order_id, + orders.customer_id, + orders.order_date, + orders.status, + + {% for payment_method in payment_methods -%} + + order_payments.{{ payment_method }}_amount, + + {% endfor -%} + + order_payments.total_amount as amount + + from orders + + + left join order_payments + on orders.order_id = order_payments.order_id + +) + +select * from final diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/overview.md b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/overview.md new file mode 100644 index 0000000000..0544c42b17 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/overview.md @@ -0,0 +1,11 @@ +{% docs __overview__ %} + +## Data Documentation for Jaffle Shop + +`jaffle_shop` is a fictional ecommerce store. + +This [dbt](https://www.getdbt.com/) project is for testing out code. + +The source code can be found [here](https://github.com/clrcrl/jaffle_shop). + +{% enddocs %} diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/schema.yml b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/schema.yml new file mode 100644 index 0000000000..381349cfda --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/schema.yml @@ -0,0 +1,82 @@ +version: 2 + +models: + - name: customers + description: This table has basic information about a customer, as well as some derived facts based on a customer's orders + + columns: + - name: customer_id + description: This is a unique identifier for a customer + tests: + - unique + - not_null + + - name: first_name + description: Customer's first name. PII. + + - name: last_name + description: Customer's last name. PII. + + - name: first_order + description: Date (UTC) of a customer's first order + + - name: most_recent_order + description: Date (UTC) of a customer's most recent order + + - name: number_of_orders + description: Count of the number of orders a customer has placed + + - name: total_order_amount + description: Total value (AUD) of a customer's orders + + - name: orders + description: This table has basic information about orders, as well as some derived facts based on payments + + columns: + - name: order_id + tests: + - unique + - not_null + description: This is a unique identifier for an order + + - name: customer_id + description: Foreign key to the customers table + tests: + - not_null + - relationships: + to: ref('customers') + field: customer_id + + - name: order_date + description: Date (UTC) that the order was placed + + - name: status + description: '{{ doc("orders_status") }}' + tests: + - accepted_values: + values: ['placed', 'shipped', 'completed', 'return_pending', 'returned'] + + - name: amount + description: Total amount (AUD) of the order + tests: + - not_null + + - name: credit_card_amount + description: Amount of the order (AUD) paid for by credit card + tests: + - not_null + + - name: coupon_amount + description: Amount of the order (AUD) paid for by coupon + tests: + - not_null + + - name: bank_transfer_amount + description: Amount of the order (AUD) paid for by bank transfer + tests: + - not_null + + - name: gift_card_amount + description: Amount of the order (AUD) paid for by gift card + tests: + - not_null diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/schema.yml b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/schema.yml new file mode 100644 index 0000000000..c207e4cf52 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/schema.yml @@ -0,0 +1,31 @@ +version: 2 + +models: + - name: stg_customers + columns: + - name: customer_id + tests: + - unique + - not_null + + - name: stg_orders + columns: + - name: order_id + tests: + - unique + - not_null + - name: status + tests: + - accepted_values: + values: ['placed', 'shipped', 'completed', 'return_pending', 'returned'] + + - name: stg_payments + columns: + - name: payment_id + tests: + - unique + - not_null + - name: payment_method + tests: + - accepted_values: + values: ['credit_card', 'coupon', 'bank_transfer', 'gift_card'] diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_customers.sql b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_customers.sql new file mode 100644 index 0000000000..cad0472695 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_customers.sql @@ -0,0 +1,22 @@ +with source as ( + + {#- + Normally we would select from the table here, but we are using seeds to load + our data in this project + #} + select * from {{ ref('raw_customers') }} + +), + +renamed as ( + + select + id as customer_id, + first_name, + last_name + + from source + +) + +select * from renamed diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_orders.sql b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_orders.sql new file mode 100644 index 0000000000..a654dcb947 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_orders.sql @@ -0,0 +1,23 @@ +with source as ( + + {#- + Normally we would select from the table here, but we are using seeds to load + our data in this project + #} + select * from {{ ref('raw_orders') }} + +), + +renamed as ( + + select + id as order_id, + user_id as customer_id, + order_date, + status + + from source + +) + +select * from renamed diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_payments.sql b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_payments.sql new file mode 100644 index 0000000000..f718596ad0 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/models/staging/stg_payments.sql @@ -0,0 +1,25 @@ +with source as ( + + {#- + Normally we would select from the table here, but we are using seeds to load + our data in this project + #} + select * from {{ ref('raw_payments') }} + +), + +renamed as ( + + select + id as payment_id, + order_id, + payment_method, + + -- `amount` is currently stored in cents, so we convert it to dollars + amount / 100 as amount + + from source + +) + +select * from renamed diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/.gitkeep b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_customers.csv b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_customers.csv new file mode 100644 index 0000000000..b3e6747d69 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_customers.csv @@ -0,0 +1,101 @@ +id,first_name,last_name +1,Michael,P. +2,Shawn,M. +3,Kathleen,P. +4,Jimmy,C. +5,Katherine,R. +6,Sarah,R. +7,Martin,M. +8,Frank,R. +9,Jennifer,F. +10,Henry,W. +11,Fred,S. +12,Amy,D. +13,Kathleen,M. +14,Steve,F. +15,Teresa,H. +16,Amanda,H. +17,Kimberly,R. +18,Johnny,K. +19,Virginia,F. +20,Anna,A. +21,Willie,H. +22,Sean,H. +23,Mildred,A. +24,David,G. +25,Victor,H. +26,Aaron,R. +27,Benjamin,B. +28,Lisa,W. +29,Benjamin,K. +30,Christina,W. +31,Jane,G. +32,Thomas,O. +33,Katherine,M. +34,Jennifer,S. +35,Sara,T. +36,Harold,O. +37,Shirley,J. +38,Dennis,J. +39,Louise,W. +40,Maria,A. +41,Gloria,C. +42,Diana,S. +43,Kelly,N. +44,Jane,R. +45,Scott,B. +46,Norma,C. +47,Marie,P. +48,Lillian,C. +49,Judy,N. +50,Billy,L. +51,Howard,R. +52,Laura,F. +53,Anne,B. +54,Rose,M. +55,Nicholas,R. +56,Joshua,K. +57,Paul,W. +58,Kathryn,K. +59,Adam,A. +60,Norma,W. +61,Timothy,R. +62,Elizabeth,P. +63,Edward,G. +64,David,C. +65,Brenda,W. +66,Adam,W. +67,Michael,H. +68,Jesse,E. +69,Janet,P. +70,Helen,F. +71,Gerald,C. +72,Kathryn,O. +73,Alan,B. +74,Harry,A. +75,Andrea,H. +76,Barbara,W. +77,Anne,W. +78,Harry,H. +79,Jack,R. +80,Phillip,H. +81,Shirley,H. +82,Arthur,D. +83,Virginia,R. +84,Christina,R. +85,Theresa,M. +86,Jason,C. +87,Phillip,B. +88,Adam,T. +89,Margaret,J. +90,Paul,P. +91,Todd,W. +92,Willie,O. +93,Frances,R. +94,Gregory,H. +95,Lisa,P. +96,Jacqueline,A. +97,Shirley,D. +98,Nicole,M. +99,Mary,G. +100,Jean,M. diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_orders.csv b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_orders.csv new file mode 100644 index 0000000000..7c2be07888 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_orders.csv @@ -0,0 +1,100 @@ +id,user_id,order_date,status +1,1,2018-01-01,returned +2,3,2018-01-02,completed +3,94,2018-01-04,completed +4,50,2018-01-05,completed +5,64,2018-01-05,completed +6,54,2018-01-07,completed +7,88,2018-01-09,completed +8,2,2018-01-11,returned +9,53,2018-01-12,completed +10,7,2018-01-14,completed +11,99,2018-01-14,completed +12,59,2018-01-15,completed +13,84,2018-01-17,completed +14,40,2018-01-17,returned +15,25,2018-01-17,completed +16,39,2018-01-18,completed +17,71,2018-01-18,completed +18,64,2018-01-20,returned +19,54,2018-01-22,completed +20,20,2018-01-23,completed +21,71,2018-01-23,completed +22,86,2018-01-24,completed +23,22,2018-01-26,return_pending +24,3,2018-01-27,completed +25,51,2018-01-28,completed +26,32,2018-01-28,completed +27,94,2018-01-29,completed +28,8,2018-01-29,completed +29,57,2018-01-31,completed +30,69,2018-02-02,completed +31,16,2018-02-02,completed +32,28,2018-02-04,completed +33,42,2018-02-04,completed +34,38,2018-02-06,completed +35,80,2018-02-08,completed +36,85,2018-02-10,completed +37,1,2018-02-10,completed +38,51,2018-02-10,completed +39,26,2018-02-11,completed +40,33,2018-02-13,completed +41,99,2018-02-14,completed +42,92,2018-02-16,completed +43,31,2018-02-17,completed +44,66,2018-02-17,completed +45,22,2018-02-17,completed +46,6,2018-02-19,completed +47,50,2018-02-20,completed +48,27,2018-02-21,completed +49,35,2018-02-21,completed +50,51,2018-02-23,completed +51,71,2018-02-24,completed +52,54,2018-02-25,return_pending +53,34,2018-02-26,completed +54,54,2018-02-26,completed +55,18,2018-02-27,completed +56,79,2018-02-28,completed +57,93,2018-03-01,completed +58,22,2018-03-01,completed +59,30,2018-03-02,completed +60,12,2018-03-03,completed +61,63,2018-03-03,completed +62,57,2018-03-05,completed +63,70,2018-03-06,completed +64,13,2018-03-07,completed +65,26,2018-03-08,completed +66,36,2018-03-10,completed +67,79,2018-03-11,completed +68,53,2018-03-11,completed +69,3,2018-03-11,completed +70,8,2018-03-12,completed +71,42,2018-03-12,shipped +72,30,2018-03-14,shipped +73,19,2018-03-16,completed +74,9,2018-03-17,shipped +75,69,2018-03-18,completed +76,25,2018-03-20,completed +77,35,2018-03-21,shipped +78,90,2018-03-23,shipped +79,52,2018-03-23,shipped +80,11,2018-03-23,shipped +81,76,2018-03-23,shipped +82,46,2018-03-24,shipped +83,54,2018-03-24,shipped +84,70,2018-03-26,placed +85,47,2018-03-26,shipped +86,68,2018-03-26,placed +87,46,2018-03-27,placed +88,91,2018-03-27,shipped +89,21,2018-03-28,placed +90,66,2018-03-30,shipped +91,47,2018-03-31,placed +92,84,2018-04-02,placed +93,66,2018-04-03,placed +94,63,2018-04-03,placed +95,27,2018-04-04,placed +96,90,2018-04-06,placed +97,89,2018-04-07,placed +98,41,2018-04-07,placed +99,85,2018-04-09,placed diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_payments.csv b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_payments.csv new file mode 100644 index 0000000000..a587baab59 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/seeds/raw_payments.csv @@ -0,0 +1,114 @@ +id,order_id,payment_method,amount +1,1,credit_card,1000 +2,2,credit_card,2000 +3,3,coupon,100 +4,4,coupon,2500 +5,5,bank_transfer,1700 +6,6,credit_card,600 +7,7,credit_card,1600 +8,8,credit_card,2300 +9,9,gift_card,2300 +10,9,bank_transfer,0 +11,10,bank_transfer,2600 +12,11,credit_card,2700 +13,12,credit_card,100 +14,13,credit_card,500 +15,13,bank_transfer,1400 +16,14,bank_transfer,300 +17,15,coupon,2200 +18,16,credit_card,1000 +19,17,bank_transfer,200 +20,18,credit_card,500 +21,18,credit_card,800 +22,19,gift_card,600 +23,20,bank_transfer,1500 +24,21,credit_card,1200 +25,22,bank_transfer,800 +26,23,gift_card,2300 +27,24,coupon,2600 +28,25,bank_transfer,2000 +29,25,credit_card,2200 +30,25,coupon,1600 +31,26,credit_card,3000 +32,27,credit_card,2300 +33,28,bank_transfer,1900 +34,29,bank_transfer,1200 +35,30,credit_card,1300 +36,31,credit_card,1200 +37,32,credit_card,300 +38,33,credit_card,2200 +39,34,bank_transfer,1500 +40,35,credit_card,2900 +41,36,bank_transfer,900 +42,37,credit_card,2300 +43,38,credit_card,1500 +44,39,bank_transfer,800 +45,40,credit_card,1400 +46,41,credit_card,1700 +47,42,coupon,1700 +48,43,gift_card,1800 +49,44,gift_card,1100 +50,45,bank_transfer,500 +51,46,bank_transfer,800 +52,47,credit_card,2200 +53,48,bank_transfer,300 +54,49,credit_card,600 +55,49,credit_card,900 +56,50,credit_card,2600 +57,51,credit_card,2900 +58,51,credit_card,100 +59,52,bank_transfer,1500 +60,53,credit_card,300 +61,54,credit_card,1800 +62,54,bank_transfer,1100 +63,55,credit_card,2900 +64,56,credit_card,400 +65,57,bank_transfer,200 +66,58,coupon,1800 +67,58,gift_card,600 +68,59,gift_card,2800 +69,60,credit_card,400 +70,61,bank_transfer,1600 +71,62,gift_card,1400 +72,63,credit_card,2900 +73,64,bank_transfer,2600 +74,65,credit_card,0 +75,66,credit_card,2800 +76,67,bank_transfer,400 +77,67,credit_card,1900 +78,68,credit_card,1600 +79,69,credit_card,1900 +80,70,credit_card,2600 +81,71,credit_card,500 +82,72,credit_card,2900 +83,73,bank_transfer,300 +84,74,credit_card,3000 +85,75,credit_card,1900 +86,76,coupon,200 +87,77,credit_card,0 +88,77,bank_transfer,1900 +89,78,bank_transfer,2600 +90,79,credit_card,1800 +91,79,credit_card,900 +92,80,gift_card,300 +93,81,coupon,200 +94,82,credit_card,800 +95,83,credit_card,100 +96,84,bank_transfer,2500 +97,85,bank_transfer,1700 +98,86,coupon,2300 +99,87,gift_card,3000 +100,87,credit_card,2600 +101,88,credit_card,2900 +102,89,bank_transfer,2200 +103,90,bank_transfer,200 +104,91,credit_card,1900 +105,92,bank_transfer,1500 +106,92,coupon,200 +107,93,gift_card,2600 +108,94,coupon,700 +109,95,coupon,2400 +110,96,gift_card,1700 +111,97,bank_transfer,1400 +112,98,bank_transfer,1000 +113,99,credit_card,2400 diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/tests/assert_total_payment_amount_is_positive.sql b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/tests/assert_total_payment_amount_is_positive.sql new file mode 100644 index 0000000000..bfd8ee6b5d --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/jaffle_shop/tests/assert_total_payment_amount_is_positive.sql @@ -0,0 +1,6 @@ +select + order_id, + sum(amount) as total_amount +from {{ ref('orders' )}} +group by 1 +having not(sum(amount) >= 0) diff --git a/flytekit/plugins/flytekit-dbt/tests/testdata/profiles/profiles.yml b/flytekit/plugins/flytekit-dbt/tests/testdata/profiles/profiles.yml new file mode 100644 index 0000000000..8d90d76768 --- /dev/null +++ b/flytekit/plugins/flytekit-dbt/tests/testdata/profiles/profiles.yml @@ -0,0 +1,11 @@ +jaffle_shop: + target: dev + outputs: + dev: + type: sqlite + threads: 1 + database: 'database' + schema: 'main' + schemas_and_paths: + main: 'dbs/database_name.db' + schema_directory: 'file_path' diff --git a/flytekit/plugins/flytekit-deck-standard/README.md b/flytekit/plugins/flytekit-deck-standard/README.md new file mode 100644 index 0000000000..719a2e77a8 --- /dev/null +++ b/flytekit/plugins/flytekit-deck-standard/README.md @@ -0,0 +1,9 @@ +# Flytekit Deck Plugin + +This Plugin provides more renderers to improve task visibility. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-deck-standard +``` diff --git a/flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/__init__.py b/flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/__init__.py new file mode 100644 index 0000000000..279adb08dd --- /dev/null +++ b/flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/__init__.py @@ -0,0 +1,17 @@ +""" +.. currentmodule:: flytekitplugins.deck + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + BoxRenderer + FrameProfilingRenderer + MarkdownRenderer + ImageRenderer + TableRenderer +""" + +from .renderer import BoxRenderer, FrameProfilingRenderer, ImageRenderer, MarkdownRenderer, TableRenderer diff --git a/flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py b/flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py new file mode 100644 index 0000000000..ff736231a1 --- /dev/null +++ b/flytekit/plugins/flytekit-deck-standard/flytekitplugins/deck/renderer.py @@ -0,0 +1,203 @@ +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.Image + import plotly.express as px +else: + pd = lazy_module("pandas") + markdown = lazy_module("markdown") + px = lazy_module("plotly.express") + PIL = lazy_module("PIL") + + +class SourceCodeRenderer: + """ + Convert Python source code to HTML, and return HTML as a unicode string. + """ + + def __init__(self, title: str = "Source Code"): + self._title = title + + def to_html(self, source_code: str) -> str: + """ + Convert the provided Python source code into HTML format using Pygments library. + + This method applies a colorful style and replaces the color "#fff0f0" with "#ffffff" in CSS. + + Args: + source_code (str): The Python source code to be converted. + + Returns: + str: The resulting HTML as a string, including CSS and highlighted source code. + """ + from pygments import highlight + from pygments.formatters.html import HtmlFormatter + from pygments.lexers.python import PythonLexer + + formatter = HtmlFormatter(style="colorful") + css = formatter.get_style_defs(".highlight").replace("#fff0f0", "#ffffff") + html = highlight(source_code, PythonLexer(), formatter) + return f"{html}" + + +class FrameProfilingRenderer: + """ + Generate a ProfileReport based on a pandas DataFrame + """ + + def __init__(self, title: str = "Pandas Profiling Report"): + self._title = 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() + + +class MarkdownRenderer: + """Convert a markdown string to HTML and return HTML as a unicode string. + + This is a shortcut function for `Markdown` class to cover the most + basic use case. It initializes an instance of Markdown, loads the + necessary extensions and runs the parser on the given text. + """ + + def to_html(self, text: str) -> str: + return markdown.markdown(text) + + +class BoxRenderer: + """ + In a box plot, rows of `data_frame` are grouped together into a + box-and-whisker mark to visualize their distribution. + + 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 + range (IQR: Q3-Q1), see "points" for other options. + """ + + # More detail, see https://plotly.com/python/box-plots/ + def __init__(self, column_name): + self._column_name = column_name + + 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/flytekit/plugins/flytekit-deck-standard/setup.py b/flytekit/plugins/flytekit-deck-standard/setup.py new file mode 100644 index 0000000000..b0d2c4783d --- /dev/null +++ b/flytekit/plugins/flytekit-deck-standard/setup.py @@ -0,0 +1,48 @@ +from setuptools import setup + +PLUGIN_NAME = "deck" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}-standard" + +plugin_requires = [ + "flytekit", + "markdown", + "plotly", + # ydata-profiling is not compatible with python 3.12 yet: https://github.com/ydataai/ydata-profiling/issues/1510 + "ydata-profiling; python_version<'3.12'", + "pandas", + "ipywidgets", + "pygments", +] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This Plugin provides more renderers to improve task visibility", + url="https://github.com/flyteorg/flytekit/tree/master/plugins/flytekit-data-fsspec", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + 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", + "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/flytekit/plugins/flytekit-deck-standard/tests/__init__.py b/flytekit/plugins/flytekit-deck-standard/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-deck-standard/tests/test_renderer.py b/flytekit/plugins/flytekit-deck-standard/tests/test_renderer.py new file mode 100644 index 0000000000..e79a015b63 --- /dev/null +++ b/flytekit/plugins/flytekit-deck-standard/tests/test_renderer.py @@ -0,0 +1,97 @@ +import datetime +import tempfile + +import markdown +import pandas as pd +import pytest +from flytekitplugins.deck.renderer import ( + BoxRenderer, + FrameProfilingRenderer, + GanttChartRenderer, + ImageRenderer, + MarkdownRenderer, + SourceCodeRenderer, + 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(): + renderer = FrameProfilingRenderer() + assert "Pandas Profiling Report" in renderer.to_html(df).title() + + +def test_markdown_renderer(): + md_text = "#Hello Flyte\n##Hello Flyte\n###Hello Flyte" + renderer = MarkdownRenderer() + assert renderer.to_html(md_text) == markdown.markdown(md_text) + + +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 " +dolt config --global --add user.name +``` + +All the [examples](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/flytekit_plugins/dolt/index.html) can be found in the documentation. diff --git a/flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/__init__.py b/flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/__init__.py new file mode 100644 index 0000000000..6e429e3963 --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/__init__.py @@ -0,0 +1,15 @@ +""" +.. currentmodule:: flytekitplugins.dolt + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + DoltConfig + DoltTable + DoltTableNameTransformer +""" + +from .schema import DoltConfig, DoltTable, DoltTableNameTransformer diff --git a/flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/schema.py b/flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/schema.py new file mode 100644 index 0000000000..ac279abfc4 --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/flytekitplugins/dolt/schema.py @@ -0,0 +1,108 @@ +import tempfile +import typing +from dataclasses import dataclass +from typing import Type + +import dolt_integrations.core as dolt_int +from dataclasses_json import DataClassJsonMixin +from google.protobuf.json_format import MessageToDict +from google.protobuf.struct_pb2 import Struct + +from flytekit import FlyteContext, lazy_module +from flytekit.extend import TypeEngine, TypeTransformer +from flytekit.models import types as _type_models +from flytekit.models.literals import Literal, Scalar +from flytekit.models.types import LiteralType + +# dolt_int = lazy_module("dolt_integrations") +dolt = lazy_module("doltcli") +pandas = lazy_module("pandas") + + +@dataclass +class DoltConfig(DataClassJsonMixin): + db_path: str + tablename: typing.Optional[str] = None + sql: typing.Optional[str] = None + io_args: typing.Optional[dict] = None + branch_conf: typing.Optional[dolt_int.Branch] = None + meta_conf: typing.Optional[dolt_int.Meta] = None + remote_conf: typing.Optional[dolt_int.Remote] = None + + +@dataclass +class DoltTable(DataClassJsonMixin): + config: DoltConfig + data: typing.Optional[pandas.DataFrame] = None + + +class DoltTableNameTransformer(TypeTransformer[DoltTable]): + def __init__(self): + super().__init__(name="DoltTable", t=DoltTable) + + def get_literal_type(self, t: Type[DoltTable]) -> LiteralType: + return LiteralType(simple=_type_models.SimpleType.STRUCT, metadata={}) + + def to_literal( + self, + ctx: FlyteContext, + python_val: DoltTable, + python_type: typing.Type[DoltTable], + expected: LiteralType, + ) -> Literal: + if not isinstance(python_val, DoltTable): + raise AssertionError(f"Value cannot be converted to a table: {python_val}") + + conf = python_val.config + if python_val.data is not None and python_val.config.tablename is not None: + db = dolt.Dolt(conf.db_path) + with tempfile.NamedTemporaryFile() as f: + python_val.data.to_csv(f.name, index=False) + message = f"Generated by Flyte execution id: {ctx.user_space_params.execution_id}" + dolt_int.save( + db=db, + tablename=conf.tablename, + filename=f.name, + branch_conf=conf.branch_conf, + meta_conf=conf.meta_conf, + remote_conf=conf.remote_conf, + save_args=conf.io_args, + commit_message=message, + ) + + s = Struct() + s.update(python_val.to_dict()) # type: ignore + return Literal(Scalar(generic=s)) + + def to_python_value( + self, + ctx: FlyteContext, + lv: Literal, + expected_python_type: typing.Type[DoltTable], + ) -> DoltTable: + if not (lv and lv.scalar and lv.scalar.generic and "config" in lv.scalar.generic): + raise ValueError("DoltTable requires DoltConfig to load python value") + + conf_dict = MessageToDict(lv.scalar.generic["config"]) + + conf = DoltConfig(**conf_dict) + db = dolt.Dolt(conf.db_path) + + with tempfile.NamedTemporaryFile() as f: + dolt_int.load( + db=db, + tablename=conf.tablename, + sql=conf.sql, + filename=f.name, + branch_conf=conf.branch_conf, + meta_conf=conf.meta_conf, + remote_conf=conf.remote_conf, + load_args=conf.io_args, + ) + df = pandas.read_csv(f) + lv.data = df + + return lv + + +TypeEngine.register(DoltTableNameTransformer()) diff --git a/flytekit/plugins/flytekit-dolt/scripts/flytekit_install_dolt.sh b/flytekit/plugins/flytekit-dolt/scripts/flytekit_install_dolt.sh new file mode 100644 index 0000000000..c2f4841789 --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/scripts/flytekit_install_dolt.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Fetches and install Dolt. To be invoked by the Dockerfile + +# echo commands to the terminal output +set -eox pipefail + +# Install Dolt + +apt-get update -y \ + && apt-get install curl \ + && sudo bash -c 'curl -L https://github.com/dolthub/dolt/releases/latest/download/install.sh | sudo bash' diff --git a/flytekit/plugins/flytekit-dolt/scripts/install.sh b/flytekit/plugins/flytekit-dolt/scripts/install.sh new file mode 100755 index 0000000000..c7b3acd283 --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/scripts/install.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# This script installs dolt on your Linux or macOS computer. +# It should be run as root, and can be run directly from a GitHub +# release, for example as: +# +# curl https://github.com/dolthub/dolt/releases/download/v0.26.3/install.sh | sudo bash +# +# All downloads occur over HTTPS from the Github releases page. + +if test -z "$BASH_VERSION"; then + echo "Please run this script using bash, not sh or any other shell." >&2 + exit 1 +fi + +_() { + +set -euo pipefail + +DOLT_VERSION=0.26.3 +RELEASES_BASE_URL=https://github.com/dolthub/dolt/releases/download/v"$DOLT_VERSION" +INSTALL_URL=$RELEASES_BASE_URL/install.sh +INSTALL_PATH=${INSTALL_PATH:-/usr/local/bin} + +CURL_USER_AGENT=${CURL_USER_AGENT:-dolt-installer} + +OS= +ARCH= +WORK_DIR= + +PLATFORM_TUPLE= + +error() { + if [ $# != 0 ]; then + echo -e "\e[0;31m" "$@" "\e[0m" >&2 + fi +} + +fail() { + shift + echo "*** INSTALLATION FAILED ***" >&2 + echo "" >&2 + error "$@" + echo "" >&2 + exit 1 +} + +assert_linux_or_macos() { + OS=$(uname) + ARCH=$(uname -m) + if [ "$OS" != Linux ] && [ "$OS" != Darwin ]; then + fail "E_UNSUPPORTED_OS" "dolt install.sh only supports macOS and Linux." + fi + if [ "$ARCH" != x86_64 ] && [ "$ARCH" != i386 ] && [ "$ARCH" != i686 ]; then + fail "E_UNSUPPOSED_ARCH" "dolt install.sh only supports installing dolt on x86_64 or x86." + fi + + if [ "$OS" == Linux ]; then + PLATFORM_TUPLE=linux + else + PLATFORM_TUPLE=darwin + fi + if [ "$ARCH" == x86_64 ]; then + PLATFORM_TUPLE=$PLATFORM_TUPLE-amd64 + else + PLATFORM_TUPLE=$PLATFORM_TUPLE-386 + fi +} + +assert_dependencies() { + type -p curl > /dev/null || fail "E_CURL_MISSING" "Please install curl(1)." + type -p tar > /dev/null || fail "E_TAR_MISSING" "Please install tar(1)." + type -p uname > /dev/null || fail "E_UNAME_MISSING" "Please install uname(1)." + type -p install > /dev/null || fail "E_INSTALL_MISSING" "Please install install(1)." + type -p mktemp > /dev/null || fail "E_MKTEMP_MISSING" "Please install mktemp(1)." +} + +assert_uid_zero() { + uid=$(id -u) + if [ "$uid" != 0 ]; then + fail "E_UID_NONZERO" "dolt install.sh must run as root; please try running with sudo or running\n\`curl $INSTALL_URL | sudo bash\`." + fi +} + +create_workdir() { + WORK_DIR=$(mktemp -d -t dolt-installer.XXXXXX) + cleanup() { + rm -rf "$WORK_DIR" + } + trap cleanup EXIT + cd "$WORK_DIR" +} + +install_binary_release() { + local FILE=dolt-$PLATFORM_TUPLE.tar.gz + local URL=$RELEASES_BASE_URL/$FILE + echo "Downloading:" $URL + curl -A "$CURL_USER_AGENT" -fsL "$URL" > "$FILE" + tar zxf "$FILE" + echo "Installing dolt, git-dolt and git-dolt-smudge to $INSTALL_PATH." + [ -d "$INSTALL_PATH" ] || install -o 0 -g 0 -d "$INSTALL_PATH" + install dolt-$PLATFORM_TUPLE/bin/{dolt,git-dolt,git-dolt-smudge} "$INSTALL_PATH" +} + +assert_linux_or_macos +assert_dependencies +create_workdir +install_binary_release + +} + +_ "$0" "$@" diff --git a/flytekit/plugins/flytekit-dolt/setup.py b/flytekit/plugins/flytekit-dolt/setup.py new file mode 100644 index 0000000000..623bf5c728 --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/setup.py @@ -0,0 +1,52 @@ +import pip +from setuptools import setup +from setuptools.command.develop import develop + +PLUGIN_NAME = "dolt" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "dolt_integrations>=0.1.5", "networkx<3.2; python_version<'3.9'"] +dev_requires = ["pytest-mock>=3.6.1"] + +__version__ = "0.0.0+develop" + + +class PostDevelopCommand(develop): + """Post-installation for development mode.""" + + def run(self): + develop.run(self) + pip.main(["install"] + dev_requires) + + +setup( + name=microlib_name, + version=__version__, + author="dolthub", + author_email="max@dolthub.com", + description="Dolt plugin for flytekit", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}"], + install_requires=plugin_requires, + extras_resquire=dict( + dev=dev_requires, + ), + cmdclass=dict(develop=PostDevelopCommand), + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + scripts=["scripts/flytekit_install_dolt.sh"], +) diff --git a/flytekit/plugins/flytekit-dolt/tests/__init__.py b/flytekit/plugins/flytekit-dolt/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-dolt/tests/test_schema.py b/flytekit/plugins/flytekit-dolt/tests/test_schema.py new file mode 100644 index 0000000000..40b5e2c7a6 --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/tests/test_schema.py @@ -0,0 +1,66 @@ +import pandas +import pytest +from flytekitplugins.dolt.schema import DoltConfig, DoltTable, DoltTableNameTransformer +from google.protobuf.struct_pb2 import Struct + +from flytekit.models.literals import Literal, Scalar + + +def test_dolt_table_to_python_value(mocker): + mocker.patch("dolt_integrations.core.save", return_value=True) + table = DoltTable(data=pandas.DataFrame(), config=DoltConfig(db_path="p")) + + lv = DoltTableNameTransformer.to_literal( + self=None, + ctx=None, + python_val=table, + python_type=DoltTable, + expected=None, + ) + + assert lv.scalar.generic["config"]["branch_conf"] is None + + +def test_dolt_table_to_literal(mocker): + df = pandas.DataFrame() + mocker.patch("dolt_integrations.core.load", return_value=None) + mocker.patch("doltcli.Dolt", return_value=None) + mocker.patch("pandas.read_csv", return_value=df) + + s = Struct() + s.update({"config": {"db_path": "", "tablename": "t"}}) + lv = Literal(Scalar(generic=s)) + + res = DoltTableNameTransformer.to_python_value( + self=None, + ctx=None, + lv=lv, + expected_python_type=DoltTable, + ) + + assert res.data.equals(df) + + +def test_dolt_table_to_python_value_error(): + with pytest.raises(AssertionError): + DoltTableNameTransformer.to_literal( + self=None, + ctx=None, + python_val=None, + python_type=DoltTable, + expected=None, + ) + + +def test_dolt_table_to_literal_error(): + s = Struct() + s.update({"dummy": "data"}) + lv = Literal(Scalar(generic=s)) + + with pytest.raises(ValueError): + DoltTableNameTransformer.to_python_value( + self=None, + ctx=None, + lv=lv, + expected_python_type=DoltTable, + ) diff --git a/flytekit/plugins/flytekit-dolt/tests/test_wf.py b/flytekit/plugins/flytekit-dolt/tests/test_wf.py new file mode 100644 index 0000000000..31dc49398a --- /dev/null +++ b/flytekit/plugins/flytekit-dolt/tests/test_wf.py @@ -0,0 +1,216 @@ +import os +import shutil +import subprocess +import tempfile +import typing +from pathlib import Path + +import doltcli as dolt +import pandas +import pytest +from dolt_integrations.core import NewBranch +from flytekitplugins.dolt.schema import DoltConfig, DoltTable + +from flytekit import task, workflow + + +@pytest.fixture(scope="module") +def dolt_install(): + """ + Every pytest instance downloads the most recent `dolt` binary, + sets it as the dolt path in Dolt's Python package, and then deletes + it afterward. + """ + tmp_dir = tempfile.TemporaryDirectory() + dir_path = os.path.dirname(os.path.realpath(__file__)) + install_script = os.path.join(dir_path, "..", "..", "flytekit-dolt", "scripts", "install.sh") + + try: + dolt_path = os.path.join(tmp_dir.name, "dolt") + subprocess.run(install_script, env={"INSTALL_PATH": tmp_dir.name}) + for attr, value in [("user.name", "First Last"), ("user.email", "first.last@example.com")]: + subprocess.run([dolt_path, "config", "--global", "--add", attr, value]) + dolt.set_dolt_path(dolt_path) + yield dolt_path + finally: + shutil.rmtree(tmp_dir.name) + shutil.rmtree(Path.home() / ".dolt") + + +@pytest.fixture(scope="function") +def doltdb_path(tmp_path, dolt_install): + with tempfile.TemporaryDirectory() as d: + db_path = os.path.join(d, "foo") + yield db_path + + +@pytest.fixture(scope="function") +def dolt_config(doltdb_path): + yield DoltConfig( + db_path=doltdb_path, + tablename="foo", + ) + + +@pytest.fixture(scope="function") +def db(doltdb_path): + try: + db = dolt.Dolt.init(doltdb_path) + db.sql("create table bar (name text primary key, count bigint)") + db.sql("insert into bar values ('Dilly', 3)") + db.sql("select dolt_commit('-am', 'Initialize bar table')") + yield db + finally: + pass + + +def test_dolt_table_write(db, dolt_config): + @task + def my_dolt(a: int) -> DoltTable: + df = pandas.DataFrame([("Alice", a)], columns=["name", "count"]) + return DoltTable(data=df, config=dolt_config) + + @workflow + def my_wf(a: int) -> DoltTable: + return my_dolt(a=a) + + x = my_wf(a=5) + assert x + assert (x.data == pandas.DataFrame([("Alice", 5)], columns=["name", "count"])).all().all() + + +def test_dolt_table_read(db, dolt_config): + @task + def my_dolt(t: DoltTable) -> str: + df = t.data + return df.name.values[0] + + @workflow + def my_wf(t: DoltTable) -> str: + return my_dolt(t=t) + + dolt_config.tablename = "bar" + x = my_wf(t=DoltTable(config=dolt_config)) + assert x == "Dilly" + + +def test_dolt_table_read_task_config(db, dolt_config): + @task + def my_dolt(t: DoltTable) -> str: + df = t.data + return df.name.values[0] + + @task + def my_table() -> DoltTable: + dolt_config.tablename = "bar" + t = DoltTable(config=dolt_config) + return t + + @workflow + def my_wf() -> str: + t = my_table() + return my_dolt(t=t) + + x = my_wf() + assert x == "Dilly" + + +def test_dolt_table_read_mixed_config(db, dolt_config): + @task + def my_dolt(t: DoltTable) -> str: + df = t.data + return df.name.values[0] + + @task + def my_table(conf: DoltConfig) -> DoltTable: + return DoltTable(config=conf) + + @workflow + def my_wf(conf: DoltConfig) -> str: + t = my_table(conf=conf) + return my_dolt(t=t) + + dolt_config.tablename = "bar" + x = my_wf(conf=dolt_config) + + assert x == "Dilly" + + +def test_dolt_sql_read(db, dolt_config): + @task + def my_dolt(t: DoltTable) -> str: + df = t.data + return df.name.values[0] + + @workflow + def my_wf(t: DoltTable) -> str: + return my_dolt(t=t) + + dolt_config.tablename = None + dolt_config.sql = "select * from bar" + x = my_wf(t=DoltTable(config=dolt_config)) + assert x == "Dilly" + + +def test_branching(db, doltdb_path): + def generate_confs(a: int) -> typing.Tuple[DoltConfig, DoltConfig, DoltConfig]: + branch_conf = NewBranch(f"run/a_is_{a}") + users_conf = DoltConfig( + db_path=doltdb_path, + tablename="users", + branch_conf=branch_conf, + ) + + query_users = DoltTable( + config=DoltConfig( + db_path=doltdb_path, + sql="select * from users where `count` > 5", + branch_conf=branch_conf, + ), + ) + + big_users_conf = DoltConfig( + db_path=doltdb_path, + tablename="big_users", + branch_conf=branch_conf, + ) + + return users_conf, query_users, big_users_conf + + @task + def get_confs(a: int) -> typing.Tuple[DoltConfig, DoltTable, DoltConfig]: + return generate_confs(a) + + @task + def populate_users(a: int, conf: DoltConfig) -> DoltTable: + users = [("George", a), ("Alice", a * 2), ("Stephanie", a * 3)] + df = pandas.DataFrame(users, columns=["name", "count"]) + return DoltTable(data=df, config=conf) + + @task + def filter_users(a: int, all_users: DoltTable, filtered_users: DoltTable, conf: DoltConfig) -> DoltTable: + usernames = filtered_users.data[["name"]] + return DoltTable(data=usernames, config=conf) + + @task + def count_users(users: DoltTable) -> int: + return users.data.shape[0] + + @workflow + def wf(a: int) -> int: + user_conf, query_conf, big_user_conf = get_confs(a=a) + users = populate_users(a=a, conf=user_conf) + big_users = filter_users(a=a, all_users=users, filtered_users=query_conf, conf=big_user_conf) + big_user_cnt = count_users(users=big_users) + return big_user_cnt + + assert wf(a=2) == 1 + assert wf(a=3) == 2 + + res = db.sql("select * from big_users as of HASHOF('run/a_is_3')", result_format="csv") + names = set([x["name"] for x in res]) + assert names == {"Alice", "Stephanie"} + + res = db.sql("select * from big_users as of HASHOF('run/a_is_2')", result_format="csv") + names = set([x["name"] for x in res]) + assert names == {"Stephanie"} diff --git a/flytekit/plugins/flytekit-duckdb/README.md b/flytekit/plugins/flytekit-duckdb/README.md new file mode 100644 index 0000000000..b914e14505 --- /dev/null +++ b/flytekit/plugins/flytekit-duckdb/README.md @@ -0,0 +1,9 @@ +# Flytekit DuckDB Plugin + +Run analytical workloads with ease using DuckDB. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-duckdb +``` diff --git a/flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py b/flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py new file mode 100644 index 0000000000..7f46dbf52e --- /dev/null +++ b/flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/__init__.py @@ -0,0 +1,11 @@ +""" +.. currentmodule:: flytekitplugins.duckdb + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + DuckDBQuery +""" + +from .task import DuckDBQuery diff --git a/flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py b/flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py new file mode 100644 index 0000000000..71c15481f4 --- /dev/null +++ b/flytekit/plugins/flytekit-duckdb/flytekitplugins/duckdb/task.py @@ -0,0 +1,121 @@ +import json +from typing import Dict, List, NamedTuple, Optional, Union + +from flytekit import PythonInstanceTask, lazy_module +from flytekit.extend import Interface +from flytekit.types.structured.structured_dataset import StructuredDataset + +duckdb = lazy_module("duckdb") +pd = lazy_module("pandas") +pa = lazy_module("pyarrow") + + +class QueryOutput(NamedTuple): + counter: int = -1 + output: Optional[str] = None + + +class DuckDBQuery(PythonInstanceTask): + _TASK_TYPE = "duckdb" + + def __init__( + self, + name: str, + query: Union[str, List[str]], + inputs: Optional[Dict[str, Union[StructuredDataset, list]]] = None, + **kwargs, + ): + """ + This method initializes the DuckDBQuery. + + Args: + name: Name of the task + query: DuckDB query to execute + inputs: The query parameters to be used while executing the query + """ + self._query = query + # create an in-memory database that's non-persistent + self._con = duckdb.connect(":memory:") + + outputs = {"result": StructuredDataset} + + super(DuckDBQuery, self).__init__( + name=name, + task_type=self._TASK_TYPE, + task_config=None, + interface=Interface(inputs=inputs, outputs=outputs), + **kwargs, + ) + + def _execute_query(self, params: list, query: str, counter: int, multiple_params: bool): + """ + This method runs the DuckDBQuery. + + Args: + params: Query parameters to use while executing the query + query: DuckDB query to execute + counter: Use counter to map user-given arguments to the query parameters + multiple_params: Set flag to indicate the presence of params for multiple queries + """ + if any(x in query for x in ("$", "?")): + if multiple_params: + counter += 1 + if not counter < len(params): + raise ValueError("Parameter doesn't exist.") + if "insert" in query.lower(): + # run executemany disregarding the number of entries to store for an insert query + yield QueryOutput(output=self._con.executemany(query, params[counter]), counter=counter) + else: + yield QueryOutput(output=self._con.execute(query, params[counter]), counter=counter) + else: + if params: + yield QueryOutput(output=self._con.execute(query, params), counter=counter) + else: + raise ValueError("Parameter not specified.") + else: + yield QueryOutput(output=self._con.execute(query), counter=counter) + + def execute(self, **kwargs) -> StructuredDataset: + # TODO: Enable iterative download after adding the functionality to structured dataset code. + params = None + for key in self.python_interface.inputs.keys(): + val = kwargs.get(key) + if isinstance(val, StructuredDataset): + # register structured dataset + self._con.register(key, val.open(pa.Table).all()) + elif isinstance(val, (pd.DataFrame, pa.Table)): + # register pandas dataframe/arrow table + self._con.register(key, val) + elif isinstance(val, list): + # copy val into params + params = val + elif isinstance(val, str): + # load into a list + params = json.loads(val) + else: + raise ValueError(f"Expected inputs of type StructuredDataset, str or list, received {type(val)}") + + final_query = self._query + query_output = QueryOutput() + # set flag to indicate the presence of params for multiple queries + multiple_params = isinstance(params[0], list) if params else False + + if isinstance(self._query, list) and len(self._query) > 1: + # loop until the penultimate query + for query in self._query[:-1]: + query_output = next( + self._execute_query( + params=params, query=query, counter=query_output.counter, multiple_params=multiple_params + ) + ) + final_query = self._query[-1] + + # fetch query output from the last query + # expecting a SELECT query + dataframe = next( + self._execute_query( + params=params, query=final_query, counter=query_output.counter, multiple_params=multiple_params + ) + ).output.arrow() + + return StructuredDataset(dataframe=dataframe) diff --git a/flytekit/plugins/flytekit-duckdb/setup.py b/flytekit/plugins/flytekit-duckdb/setup.py new file mode 100644 index 0000000000..ff16057728 --- /dev/null +++ b/flytekit/plugins/flytekit-duckdb/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup + +PLUGIN_NAME = "duckdb" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.3.0b2,<2.0.0", "duckdb", "pandas"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="DuckDB Plugin for Flytekit", + namespace_packages=["flytekitplugins"], + packages=[f"flytekitplugins.{PLUGIN_NAME}"], + install_requires=plugin_requires, + license="apache2", + python_requires=">=3.7,<3.12", + classifiers=[ + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/flytekit/plugins/flytekit-duckdb/tests/test_task.py b/flytekit/plugins/flytekit-duckdb/tests/test_task.py new file mode 100644 index 0000000000..e2b4450ba6 --- /dev/null +++ b/flytekit/plugins/flytekit-duckdb/tests/test_task.py @@ -0,0 +1,148 @@ +import json +from typing import List + +import pandas as pd +import pyarrow as pa +from flytekitplugins.duckdb import DuckDBQuery +from typing_extensions import Annotated + +from flytekit import kwtypes, task, workflow +from flytekit.types.structured.structured_dataset import StructuredDataset + + +def test_simple(): + simple_duckdb_query = DuckDBQuery( + name="duckdb_task", query="SELECT SUM(a) FROM mydf", inputs=kwtypes(mydf=pd.DataFrame) + ) + + @workflow + def pandas_wf(mydf: pd.DataFrame) -> pd.DataFrame: + return simple_duckdb_query(mydf=mydf) + + @workflow + def arrow_wf(mydf: pd.DataFrame) -> pa.Table: + return simple_duckdb_query(mydf=mydf) + + df = pd.DataFrame({"a": [1, 2, 3]}) + assert isinstance(pandas_wf(mydf=df), pd.DataFrame) + assert isinstance(arrow_wf(mydf=df), pa.Table) + + +def test_parquet(): + parquet_duckdb_query = DuckDBQuery( + name="read_parquet", + query=[ + "INSTALL httpfs", + "LOAD httpfs", + """SELECT hour(lpep_pickup_datetime) AS hour, count(*) AS count FROM READ_PARQUET(?) GROUP BY hour""", + ], + inputs=kwtypes(params=List[str]), + ) + + @workflow + def parquet_wf(parquet_file: str) -> pd.DataFrame: + return parquet_duckdb_query(params=[parquet_file]) + + assert isinstance( + parquet_wf(parquet_file="https://d37ci6vzurychx.cloudfront.net/trip-data/green_tripdata_2022-02.parquet"), + pd.DataFrame, + ) + + +def test_arrow(): + arrow_duckdb_query = DuckDBQuery( + name="duckdb_arrow_task", query="SELECT * FROM arrow_table WHERE i = 2", inputs=kwtypes(arrow_table=pa.Table) + ) + + @task + def get_arrow_table() -> pa.Table: + return pa.Table.from_pydict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) + + @workflow + def arrow_wf() -> pa.Table: + return arrow_duckdb_query(arrow_table=get_arrow_table()) + + assert isinstance(arrow_wf(), pa.Table) + + +def test_structured_dataset_arrow_table(): + sd_duckdb_query = DuckDBQuery( + name="duckdb_sd_table", + query="SELECT * FROM arrow_table WHERE i = 2", + inputs=kwtypes(arrow_table=StructuredDataset), + ) + + @task + def get_arrow_table() -> StructuredDataset: + return StructuredDataset( + dataframe=pa.Table.from_pydict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) + ) + + @workflow + def arrow_wf() -> pa.Table: + return sd_duckdb_query(arrow_table=get_arrow_table()) + + assert isinstance(arrow_wf(), pa.Table) + + +def test_structured_dataset_pandas_df(): + sd_pandas_duckdb_query = DuckDBQuery( + name="duckdb_sd_df", + query="SELECT * FROM pandas_df WHERE i = 2", + inputs=kwtypes(pandas_df=StructuredDataset), + ) + + @task + def get_pandas_df() -> StructuredDataset: + return StructuredDataset( + dataframe=pd.DataFrame.from_dict({"i": [1, 2, 3, 4], "j": ["one", "two", "three", "four"]}) + ) + + @workflow + def pandas_wf() -> pd.DataFrame: + return sd_pandas_duckdb_query(pandas_df=get_pandas_df()) + + assert isinstance(pandas_wf(), pd.DataFrame) + + +def test_distinct_params(): + duckdb_params_query = DuckDBQuery( + name="params_query", + query=[ + "CREATE TABLE items(item VARCHAR, value DECIMAL(10,2), count INTEGER)", + "INSERT INTO items VALUES (?, ?, ?)", + "SELECT $1 AS one, $1 AS two, $2 AS three", + ], + inputs=kwtypes(params=str), + ) + + @task + def read_df(df: Annotated[StructuredDataset, kwtypes(one=str)]) -> pd.DataFrame: + return df.open(pd.DataFrame).all() + + @workflow + def params_wf(params: str) -> pd.DataFrame: + return read_df(df=duckdb_params_query(params=params)) + + params = [[["chainsaw", 500, 10], ["iphone", 300, 2]], ["duck", "goose"]] + wf_output = params_wf(params=json.dumps(params)) + assert isinstance(wf_output, pd.DataFrame) + assert wf_output.columns.values == ["one"] + + +def test_insert_query_with_single_params(): + duckdb_params_query = DuckDBQuery( + name="params_query", + query=[ + "CREATE TABLE items(value DECIMAL(10,2))", + "INSERT INTO items VALUES (?)", + "SELECT * FROM items", + ], + inputs=kwtypes(params=str), + ) + + @workflow + def params_wf(params: str) -> pa.Table: + return duckdb_params_query(params=params) + + assert isinstance(params_wf(params=json.dumps([[[500], [300], [2]]])), pa.Table) diff --git a/flytekit/plugins/flytekit-envd/README.md b/flytekit/plugins/flytekit-envd/README.md new file mode 100644 index 0000000000..3b3a168d75 --- /dev/null +++ b/flytekit/plugins/flytekit-envd/README.md @@ -0,0 +1,44 @@ +# Flytekit Envd Plugin + +[envd](https://github.com/tensorchord/envd) is a command-line tool that helps you create the container-based development environment for AI/ML. + +Environments built with envd provide the following features out-of-the-box: +- Knowledge reuse in your team +- BuiltKit native, build up to 6x faster +- Smaller and leaner images + +With `flytekitplugins-envd`, people easily create a docker image for the workflows without writing a docker file. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-envd +``` + +Example +```python +# from flytekit import task +# from flytekit.image_spec import ImageSpec +# +# @task(image_spec=ImageSpec(packages=["pandas", "numpy"], apt_packages=["git"], registry="flyteorg")) +# def t1() -> str: +# return "hello" +``` + +This plugin also supports install packages from `conda`: + +```python +from flytekit import task, ImageSpec + +image_spec = ImageSpec( + base_image="ubuntu:20.04", + python_version="3.11", + packages=["flytekit"], + conda_packages=["pytorch", "pytorch-cuda=12.1"], + conda_channels=["pytorch", "nvidia"] +) + +@task(container_image=image_spec) +def run_pytorch(): + ... +``` diff --git a/flytekit/plugins/flytekit-envd/flytekitplugins/envd/__init__.py b/flytekit/plugins/flytekit-envd/flytekitplugins/envd/__init__.py new file mode 100644 index 0000000000..d3dec806a1 --- /dev/null +++ b/flytekit/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/flytekit/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py b/flytekit/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py new file mode 100644 index 0000000000..6c11bd6838 --- /dev/null +++ b/flytekit/plugins/flytekit-envd/flytekitplugins/envd/image_builder.py @@ -0,0 +1,124 @@ +import os +import pathlib +import shutil +import subprocess +from importlib import metadata + +import click +from packaging.version import Version + +from flytekit.configuration import DefaultImages +from flytekit.core import context_manager +from flytekit.core.constants import REQUIREMENTS_FILE_NAME +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 execute_command(self, command): + click.secho(f"Run command: {command} ", fg="blue") + p = subprocess.Popen(command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + result = [] + for line in iter(p.stdout.readline, ""): + if p.poll() is not None: + break + if line.decode().strip() != "": + output = line.decode().strip() + click.secho(output, fg="blue") + result.append(output) + + if p.returncode != 0: + _, stderr = p.communicate() + raise Exception(f"failed to run command {command} with error {stderr}") + + return result + + def build_image(self, image_spec: ImageSpec): + cfg_path = create_envd_config(image_spec) + + if image_spec.registry_config: + bootstrap_command = f"envd bootstrap --registry-config {image_spec.registry_config}" + self.execute_command(bootstrap_command) + + build_command = f"envd build --path {pathlib.Path(cfg_path).parent} --platform {image_spec.platform}" + if image_spec.registry: + build_command += f" --output type=image,name={image_spec.image_name()},push=true" + self.execute_command(build_command) + + +def _create_str_from_package_list(packages): + if packages is None: + return "" + return ", ".join(f'"{name}"' for name in packages) + + +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 + if image_spec.cuda: + if image_spec.python_version is None: + raise Exception("python_version is required when cuda and cudnn are specified") + base_image = "ubuntu20.04" + + python_packages = _create_str_from_package_list(image_spec.packages) + conda_packages = _create_str_from_package_list(image_spec.conda_packages) + run_commands = _create_str_from_package_list(image_spec.commands) + conda_channels = _create_str_from_package_list(image_spec.conda_channels) + apt_packages = _create_str_from_package_list(image_spec.apt_packages) + env = {"PYTHONPATH": "/root", _F_IMG_ID: image_spec.image_name()} + + if image_spec.env: + env.update(image_spec.env) + pip_index = "https://pypi.org/simple" if image_spec.pip_index is None else image_spec.pip_index + + envd_config = f"""# syntax=v1 + +def build(): + base(image="{base_image}", dev=False) + run(commands=[{run_commands}]) + install.python_packages(name=[{python_packages}]) + install.apt_packages(name=[{apt_packages}]) + runtime.environ(env={env}, extra_path=['/root']) + config.pip_index(url="{pip_index}") +""" + 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 conda_packages: + envd_config += " install.conda(use_mamba=True)\n" + envd_config += f" install.conda_packages(name=[{conda_packages}], channel=[{conda_channels}])\n" + + if image_spec.requirements: + requirement_path = f"{pathlib.Path(cfg_path).parent}{os.sep}{REQUIREMENTS_FILE_NAME}" + shutil.copyfile(image_spec.requirements, requirement_path) + envd_config += f' install.python_packages(requirements="{REQUIREMENTS_FILE_NAME}")\n' + + if image_spec.python_version: + # Indentation is required by envd + envd_config += f' install.python(version="{image_spec.python_version}")\n' + + if image_spec.cuda: + cudnn = image_spec.cudnn if image_spec.cudnn else "" + envd_config += f' install.cuda(version="{image_spec.cuda}", cudnn="{cudnn}")\n' + + if image_spec.source_root: + shutil.copytree(image_spec.source_root, pathlib.Path(cfg_path).parent, dirs_exist_ok=True) + + envd_version = metadata.version("envd") + # Indentation is required by envd + if Version(envd_version) <= Version("0.3.37"): + envd_config += ' io.copy(host_path="./", envd_path="/root")' + else: + envd_config += ' io.copy(source="./", target="/root")' + + with open(cfg_path, "w+") as f: + f.write(envd_config) + + return cfg_path + + +ImageBuildEngine.register("envd", EnvdImageSpecBuilder()) diff --git a/flytekit/plugins/flytekit-envd/setup.py b/flytekit/plugins/flytekit-envd/setup.py new file mode 100644 index 0000000000..d95a260958 --- /dev/null +++ b/flytekit/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/flytekit/plugins/flytekit-envd/tests/__init__.py b/flytekit/plugins/flytekit-envd/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-envd/tests/test_image_spec.py b/flytekit/plugins/flytekit-envd/tests/test_image_spec.py new file mode 100644 index 0000000000..d77b2ca89b --- /dev/null +++ b/flytekit/plugins/flytekit-envd/tests/test_image_spec.py @@ -0,0 +1,86 @@ +from pathlib import Path +from textwrap import dedent + +import pytest +from flytekitplugins.envd.image_builder import EnvdImageSpecBuilder, create_envd_config + +from flytekit.image_spec.image_spec import ImageBuildEngine, ImageSpec + + +@pytest.fixture(scope="module", autouse=True) +def register_envd_higher_priority(): + # Register a new envd platform with the highest priority so the test in this file uses envd + highest_priority_builder = max(ImageBuildEngine._REGISTRY, key=ImageBuildEngine._REGISTRY.get) + highest_priority = ImageBuildEngine._REGISTRY[highest_priority_builder][1] + yield ImageBuildEngine.register( + "envd_high_priority", + EnvdImageSpecBuilder(), + priority=highest_priority + 1, + ) + del ImageBuildEngine._REGISTRY["envd_high_priority"] + + +def test_image_spec(): + image_spec = ImageSpec( + packages=["pandas"], + apt_packages=["git"], + python_version="3.8", + base_image="cr.flyte.org/flyteorg/flytekit:py3.8-latest", + pip_index="https://private-pip-index/simple", + ) + + image_spec = image_spec.with_commands("echo hello") + + EnvdImageSpecBuilder().build_image(image_spec) + config_path = create_envd_config(image_spec) + assert image_spec.platform == "linux/amd64" + image_name = image_spec.image_name() + contents = Path(config_path).read_text() + assert ( + contents + == f"""# syntax=v1 + +def build(): + base(image="cr.flyte.org/flyteorg/flytekit:py3.8-latest", dev=False) + run(commands=["echo hello"]) + install.python_packages(name=["pandas"]) + install.apt_packages(name=["git"]) + runtime.environ(env={{'PYTHONPATH': '/root', '_F_IMG_ID': '{image_name}'}}, extra_path=['/root']) + config.pip_index(url="https://private-pip-index/simple") + install.python(version="3.8") +""" + ) + + +def test_image_spec_conda(): + image_spec = ImageSpec( + base_image="ubuntu:20.04", + python_version="3.11", + packages=["flytekit"], + conda_packages=["pytorch", "cpuonly"], + conda_channels=["pytorch"], + ) + + EnvdImageSpecBuilder().build_image(image_spec) + config_path = create_envd_config(image_spec) + assert image_spec.platform == "linux/amd64" + image_name = image_spec.image_name() + contents = Path(config_path).read_text() + expected_contents = dedent( + f"""\ + # syntax=v1 + + def build(): + base(image="ubuntu:20.04", dev=False) + run(commands=[]) + install.python_packages(name=["flytekit"]) + install.apt_packages(name=[]) + runtime.environ(env={{'PYTHONPATH': '/root', '_F_IMG_ID': '{image_name}'}}, extra_path=['/root']) + config.pip_index(url="https://pypi.org/simple") + install.conda(use_mamba=True) + install.conda_packages(name=["pytorch", "cpuonly"], channel=["pytorch"]) + install.python(version="3.11") + """ + ) + + assert contents == expected_contents diff --git a/flytekit/plugins/flytekit-flyteinteractive/Dockerfile b/flytekit/plugins/flytekit-flyteinteractive/Dockerfile new file mode 100644 index 0000000000..b07627b065 --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.10-slim-bookworm +MAINTAINER Flyte Team +LABEL org.opencontainers.image.source https://github.com/flyteorg/flytekit +WORKDIR /root +ENV PYTHONPATH /root + +ARG VERSION +ARG TARGETARCH + +# 1. Update the necessary packages for flytekit +# 2. Install code-server +# 3. Download code-server extensions for Python and Jupyter via wget +# 4. Install flytekit and flytekit-flyteinteractive with no cache +# 5. Delete apt cache. Reference: https://gist.github.com/marvell/7c812736565928e602c4 +# 6. Some packages will create config file under /home by default, so we need to make sure it's writable +# 7. Change the permission of /tmp, so that others can run command on it +RUN apt-get update \ + && apt-get install build-essential wget -y \ + && mkdir -p /tmp/ \ + && mkdir -p /tmp/code-server \ + && wget --no-check-certificate -O /tmp/code-server/code-server-4.19.0-linux-${TARGETARCH}.tar.gz https://github.com/coder/code-server/releases/download/v4.19.0/code-server-4.19.0-linux-${TARGETARCH}.tar.gz \ + && tar -xzf /tmp/code-server/code-server-4.19.0-linux-${TARGETARCH}.tar.gz -C /tmp/code-server/ \ + && wget --no-check-certificate https://open-vsx.org/api/ms-python/python/2023.20.0/file/ms-python.python-2023.20.0.vsix -P /tmp/code-server \ + && wget --no-check-certificate https://open-vsx.org/api/ms-toolsai/jupyter/2023.9.100/file/ms-toolsai.jupyter-2023.9.100.vsix -P /tmp/code-server \ + && pip install --no-cache-dir -U flytekitplugins-flyteinteractive==$VERSION flytekit==$VERSION \ + && apt-get clean autoclean \ + && apt-get autoremove --yes \ + && rm -rf /var/lib/{apt,dpkg,cache,log}/ \ + && useradd -u 1000 flytekit \ + && chown -R flytekit:flytekit /tmp/code-server \ + && chown flytekit: /root \ + && chown flytekit: /home \ + && : + +# Set the environment variable for code-server +ENV PATH="/tmp/code-server/code-server-4.19.0-linux-${TARGETARCH}/bin:${PATH}" + +USER flytekit + +# Install extensions using code-server +# Execution is performed here as code-server configuration depends on the USER setting +# If we install it as ROOT, the config will be stored in /root/.config/code-server/config.yaml +# Now, the config of code-server will be stored in /home/flytekit/.config/code-server/config.yaml +RUN code-server --install-extension /tmp/code-server/ms-python.python-2023.20.0.vsix \ + && code-server --install-extension /tmp/code-server/ms-toolsai.jupyter-2023.9.100.vsix diff --git a/flytekit/plugins/flytekit-flyteinteractive/README.md b/flytekit/plugins/flytekit-flyteinteractive/README.md new file mode 100644 index 0000000000..7142475654 --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/README.md @@ -0,0 +1,105 @@ +# Flytekit FlyteInteractive Plugin + +> FlyteInteractive = Develop Flyte with the speed of flying + +FlyteInteractive plugin provides users' favorite interface to develop and debug a flyte task interactively. We support vscode, jupyter (WIP), and neovim (WIP). + +## Installation + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-flyteinteractive +``` + +## Vscode + +FlyteInteractive Vscode offers an easy solution for users to run Python tasks within an interactive VSCode server, compatible with any image. `@vscode` is a decorator which users can put within @task and user function. With `@vscode`, the task will install vscode dependencies (skip if they already exist) and run a vscode server instead of the user defined functions. + + +## Starter Example +```python +from flytekit import task +from flytekitplugins.flyteinteractive import vscode + +@task +@vscode +def train(): + ... +``` + +## User Guide +1. Build the image with Dockerfile.dev `docker buildx build --push . -f Dockerfile.dev -t localhost:30000/flytekit:dev --build-arg PYTHON_VERSION=3.8` +2. Run the decorated task on the remote. For example: `pyflyte run --remote --image localhost:30000/flytekit:dev [PYTHONFILE] [WORKFLOW|TASK] [ARGS]...` +3. Once the code server is prepared, you can forward a local port to the pod. For example: `kubectl port-forward -n [NAMESPACE] [PODNAME] 8080:8080`. +4. You can access the server by opening a web browser and navigating to `localhost:8080`. + +VSCode example screenshot: + + +## Build Custom Image with VSCode Plugin +If users want to skip the vscode downloading process at runtime, they have the option to create a custom image with vscode by including the following lines in their Dockerfile. +```Dockerfile +# Include this line if the image does not already have 'curl' installed. ++ RUN apt-get -y install curl +# Download and extract the binary, and ensure it's added to the system's $PATH. ++ RUN mkdir /tmp/code-server ++ RUN curl -kfL -o /tmp/code-server/code-server-4.18.0-linux-amd64.tar.gz https://github.com/coder/code-server/releases/download/v4.18.0/code-server-4.18.0-linux-amd64.tar.gz ++ RUN tar -xzf /tmp/code-server/code-server-4.18.0-linux-amd64.tar.gz -C /tmp/code-server/ ++ ENV PATH="/tmp/code-server/code-server-4.18.0-linux-amd64/bin:${PATH}" +``` + +## Advanced Examples + +```python +from flytekit import task, workflow +from flytekitplugins.flyteinteractive import vscode, VscodeConfig, DEFAULT_CODE_SERVER_EXTENSIONS + +@task( + container_image="localhost:30000/flytekit-vscode:0.0.2", + environment={"FLYTE_SDK_LOGGING_LEVEL": "20"} +) +@vscode( + +) +def t(): + ... + + + +# this vscode task will be killed within 10 secs +@task( + container_image="localhost:30000/flytekit-vscode:0.0.2", + environment={"FLYTE_SDK_LOGGING_LEVEL": "20"} +) +@vscode( + max_idle_seconds=10, +) +def t_short_live(): + ... + + + +# this vscode task will download default extension + vim extension +config_with_vim = VscodeConfig( + extension_remote_paths=DEFAULT_CODE_SERVER_EXTENSIONS+["https://open-vsx.org/api/vscodevim/vim/1.27.0/file/vscodevim.vim-1.27.0.vsix"] +) + +@task( + container_image="localhost:30000/flytekit-vscode:0.0.2", + environment={"FLYTE_SDK_LOGGING_LEVEL": "20"} +) +@vscode( + config=config_with_vim +) +def t_vim(): + ... + + + +@workflow +def wf(): + t() + t_short_live() + t_vim() +``` diff --git a/flytekit/plugins/flytekit-flyteinteractive/docs/example.png b/flytekit/plugins/flytekit-flyteinteractive/docs/example.png new file mode 100644 index 0000000000000000000000000000000000000000..0f98ab3773f4a0d9100ac64197a9a087b7aba3fc GIT binary patch literal 905620 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

V*`_H$YC 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

}S;}cw}JX+ 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 P20q(@!-A@|#2xz&Z8D^$ literal 0 HcmV?d00001 diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/__init__.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/__init__.py new file mode 100644 index 0000000000..439436049a --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/__init__.py @@ -0,0 +1,41 @@ +""" +.. currentmodule:: flytekitplugins.flyteinteractive + +This package contains flyteinteractive plugin for Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + vscode + VscodeConfig + DEFAULT_CODE_SERVER_DIR_NAME + DEFAULT_CODE_SERVER_REMOTE_PATH + DEFAULT_CODE_SERVER_EXTENSIONS + COPILOT_EXTENSION + VIM_EXTENSION + CODE_TOGETHER_EXTENSION + VIM_CONFIG + COPILOT_CONFIG + CODE_TOGETHER_CONFIG + jupyter + get_task_inputs +""" + +from .jupyter_lib.decorator import jupyter +from .utils import get_task_inputs +from .vscode_lib.config import ( + CODE_TOGETHER_CONFIG, + CODE_TOGETHER_EXTENSION, + COPILOT_CONFIG, + COPILOT_EXTENSION, + VIM_CONFIG, + VIM_EXTENSION, + VscodeConfig, +) +from .vscode_lib.constants import ( + DEFAULT_CODE_SERVER_DIR_NAMES, + DEFAULT_CODE_SERVER_EXTENSIONS, + DEFAULT_CODE_SERVER_REMOTE_PATHS, +) +from .vscode_lib.decorator import vscode diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/__init__.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/constants.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/constants.py new file mode 100644 index 0000000000..6e296fbc56 --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/constants.py @@ -0,0 +1,3 @@ +# Default no activity timeout to terminate the jupyter notebook +HOURS_TO_SECONDS = 60 * 60 +MAX_IDLE_SECONDS = 10 * HOURS_TO_SECONDS # 10 hours diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/decorator.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/decorator.py new file mode 100644 index 0000000000..482d0defdc --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/jupyter_lib/decorator.py @@ -0,0 +1,64 @@ +import subprocess +import sys +from functools import wraps +from typing import Callable, Optional + +from flytekit.loggers import logger + +from .constants import MAX_IDLE_SECONDS + + +def jupyter( + _task_function: Optional[Callable] = None, + max_idle_seconds: Optional[int] = MAX_IDLE_SECONDS, + port: Optional[int] = 8888, + enable: Optional[bool] = True, + notebook_dir: Optional[str] = "/root", + pre_execute: Optional[Callable] = None, + post_execute: Optional[Callable] = None, +): + def wrapper(fn): + if not enable: + return fn + + @wraps(fn) + def inner_wrapper(*args, **kwargs): + # 0. Executes the pre_execute function if provided. + if pre_execute is not None: + pre_execute() + logger.info("Pre execute function executed successfully!") + + # 1. Launches and monitors the Jupyter Notebook server. + # The following line starts a Jupyter Notebook server with specific configurations: + # - '--port': Specifies the port number on which the server will listen for connections. + # - '--notebook-dir': Sets the directory where Jupyter Notebook will look for notebooks. + # - '--NotebookApp.token='': Disables token-based authentication by setting an empty token. + logger.info("Start the jupyter notebook server...") + cmd = f"jupyter notebook --port {port} --notebook-dir={notebook_dir} --NotebookApp.token=''" + + # - '--NotebookApp.shutdown_no_activity_timeout': Sets the maximum duration of inactivity + # before shutting down the Jupyter Notebook server automatically. + # When shutdown_no_activity_timeout is 0, it means there is no idle timeout and it is always running. + if max_idle_seconds: + cmd += f" --NotebookApp.shutdown_no_activity_timeout={max_idle_seconds}" + + logger.info(cmd) + process = subprocess.Popen(cmd, shell=True) + + # 2. Wait for the process to finish + process.wait() + + # 3. Exit after subprocess has finished + if post_execute is not None: + post_execute() + logger.info("Post execute function executed successfully!") + sys.exit() + + return inner_wrapper + + # for the case when the decorator is used without arguments + if _task_function is not None: + return wrapper(_task_function) + # for the case when the decorator is used with arguments + else: + return wrapper diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/utils.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/utils.py new file mode 100644 index 0000000000..3c13951a2e --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/utils.py @@ -0,0 +1,57 @@ +import importlib +import os +import sys + +from flyteidl.core import literals_pb2 as _literals_pb2 + +from flytekit.core import utils +from flytekit.core.context_manager import FlyteContextManager +from flytekit.core.type_engine import TypeEngine +from flytekit.models import literals as _literal_models + + +def load_module_from_path(module_name, path): + """ + Imports a Python module from a specified file path. + + Args: + module_name (str): The name you want to assign to the imported module. + path (str): The file system path to the Python file (.py) that contains the module you want to import. + + Returns: + module: The imported module. + + Raises: + ImportError: If the module cannot be loaded from the provided path, an ImportError is raised. + """ + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is not None: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + else: + raise ImportError(f"Module at {path} could not be loaded") + + +def get_task_inputs(task_module_name, task_name, context_working_dir): + """ + Read task input data from inputs.pb for a specific task function and convert it into Python types and structures. + + Args: + task_module_name (str): The name of the Python module containing the task function. + task_name (str): The name of the task function within the module. + context_working_dir (str): The directory path where the input file and module file are located. + + Returns: + dict: A dictionary containing the task inputs, converted into Python types and structures. + """ + local_inputs_file = os.path.join(context_working_dir, "inputs.pb") + input_proto = utils.load_proto_from_file(_literals_pb2.LiteralMap, local_inputs_file) + idl_input_literals = _literal_models.LiteralMap.from_flyte_idl(input_proto) + task_module = load_module_from_path(task_module_name, os.path.join(context_working_dir, f"{task_module_name}.py")) + task_def = getattr(task_module, task_name) + native_inputs = TypeEngine.literal_map_to_kwargs( + FlyteContextManager().current_context(), idl_input_literals, task_def.python_interface.inputs + ) + return native_inputs diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/__init__.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/config.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/config.py new file mode 100644 index 0000000000..332e5a7108 --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/config.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Union + +from .constants import DEFAULT_CODE_SERVER_DIR_NAMES, DEFAULT_CODE_SERVER_EXTENSIONS, DEFAULT_CODE_SERVER_REMOTE_PATHS + + +@dataclass +class VscodeConfig: + """ + VscodeConfig is the config contains default URLs of the VSCode server and extension remote paths. + + Args: + code_server_remote_paths (Dict[str, str], optional): The URL of the code-server tarball. + code_server_dir_names (Dict[str, str], optional): The name of the code-server directory. + extension_remote_paths (List[str], optional): The URLs of the VSCode extensions. + You can find all available extensions at https://open-vsx.org/. + """ + + code_server_remote_paths: Optional[Dict[str, str]] = field(default_factory=lambda: DEFAULT_CODE_SERVER_REMOTE_PATHS) + code_server_dir_names: Optional[Dict[str, str]] = field(default_factory=lambda: DEFAULT_CODE_SERVER_DIR_NAMES) + extension_remote_paths: Optional[List[str]] = field(default_factory=lambda: DEFAULT_CODE_SERVER_EXTENSIONS) + + def add_extensions(self, extensions: Union[str, List[str]]): + """ + Add additional extensions to the extension_remote_paths list. + """ + if isinstance(extensions, List): + self.extension_remote_paths.extend(extensions) + else: + self.extension_remote_paths.append(extensions) + + +# Extension URLs for additional extensions +COPILOT_EXTENSION = ( + "https://raw.githubusercontent.com/flyteorg/flytetools/master/flytekitplugins/flyin/GitHub.copilot-1.138.563.vsix" +) +VIM_EXTENSION = ( + "https://raw.githubusercontent.com/flyteorg/flytetools/master/flytekitplugins/flyin/vscodevim.vim-1.27.0.vsix" +) +CODE_TOGETHER_EXTENSION = "https://raw.githubusercontent.com/flyteorg/flytetools/master/flytekitplugins/flyin/genuitecllc.codetogether-2023.2.0.vsix" + +# Predefined VSCode config with extensions +VIM_CONFIG = VscodeConfig( + code_server_remote_paths=DEFAULT_CODE_SERVER_REMOTE_PATHS, + code_server_dir_names=DEFAULT_CODE_SERVER_DIR_NAMES, + extension_remote_paths=DEFAULT_CODE_SERVER_EXTENSIONS + [VIM_EXTENSION], +) + +COPILOT_CONFIG = VscodeConfig( + code_server_remote_paths=DEFAULT_CODE_SERVER_REMOTE_PATHS, + code_server_dir_names=DEFAULT_CODE_SERVER_DIR_NAMES, + extension_remote_paths=DEFAULT_CODE_SERVER_EXTENSIONS + [COPILOT_EXTENSION], +) + +CODE_TOGETHER_CONFIG = VscodeConfig( + code_server_remote_paths=DEFAULT_CODE_SERVER_REMOTE_PATHS, + code_server_dir_names=DEFAULT_CODE_SERVER_DIR_NAMES, + extension_remote_paths=DEFAULT_CODE_SERVER_EXTENSIONS + [CODE_TOGETHER_EXTENSION], +) diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/constants.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/constants.py new file mode 100644 index 0000000000..6fa38b708e --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/constants.py @@ -0,0 +1,41 @@ +import os + +# Where the code-server tar and plugins are downloaded to +EXECUTABLE_NAME = "code-server" +DOWNLOAD_DIR = "/tmp/code-server" +HOURS_TO_SECONDS = 60 * 60 +DEFAULT_UP_SECONDS = 10 * HOURS_TO_SECONDS # 10 hours +DEFAULT_CODE_SERVER_REMOTE_PATHS = { + "amd64": "https://github.com/coder/code-server/releases/download/v4.18.0/code-server-4.18.0-linux-amd64.tar.gz", + "arm64": "https://github.com/coder/code-server/releases/download/v4.18.0/code-server-4.18.0-linux-arm64.tar.gz", +} +DEFAULT_CODE_SERVER_EXTENSIONS = [ + "https://raw.githubusercontent.com/flyteorg/flytetools/master/flytekitplugins/flyin/ms-python.python-2023.20.0.vsix", + "https://raw.githubusercontent.com/flyteorg/flytetools/master/flytekitplugins/flyin/ms-toolsai.jupyter-2023.9.100.vsix", +] +DEFAULT_CODE_SERVER_DIR_NAMES = { + "amd64": "code-server-4.18.0-linux-amd64", + "arm64": "code-server-4.18.0-linux-arm64", +} +# Default max idle seconds to terminate the vscode server +HOURS_TO_SECONDS = 60 * 60 +MAX_IDLE_SECONDS = 10 * HOURS_TO_SECONDS # 10 hours + +# Duration to pause the checking of the heartbeat file until the next one +HEARTBEAT_CHECK_SECONDS = 60 + +# The path is hardcoded by code-server +# https://coder.com/docs/code-server/latest/FAQ#what-is-the-heartbeat-file +HEARTBEAT_PATH = os.path.expanduser("~/.local/share/code-server/heartbeat") + +INTERACTIVE_DEBUGGING_FILE_NAME = "flyteinteractive_interactive_entrypoint.py" +RESUME_TASK_FILE_NAME = "flyteinteractive_resume_task.py" +# Config keys to store in task template +VSCODE_TYPE_KEY = "flyteinteractive_type" +VSCODE_PORT_KEY = "flyteinteractive_port" + +# Context attribute name of the task function's source file path +TASK_FUNCTION_SOURCE_PATH = "TASK_FUNCTION_SOURCE_PATH" + +# Subprocess constants +EXIT_CODE_SUCCESS = 0 diff --git a/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/decorator.py b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/decorator.py new file mode 100644 index 0000000000..8ff74997be --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/flytekitplugins/flyteinteractive/vscode_lib/decorator.py @@ -0,0 +1,489 @@ +import inspect +import json +import multiprocessing +import os +import platform +import shutil +import signal +import subprocess +import sys +import tarfile +import time +from threading import Event +from typing import Callable, List, Optional + +import fsspec +from flytekitplugins.flyteinteractive.utils import load_module_from_path + +import flytekit +from flytekit.core.context_manager import FlyteContextManager +from flytekit.core.utils import ClassDecorator + +from .config import VscodeConfig +from .constants import ( + DOWNLOAD_DIR, + EXECUTABLE_NAME, + EXIT_CODE_SUCCESS, + HEARTBEAT_CHECK_SECONDS, + HEARTBEAT_PATH, + INTERACTIVE_DEBUGGING_FILE_NAME, + MAX_IDLE_SECONDS, + RESUME_TASK_FILE_NAME, + TASK_FUNCTION_SOURCE_PATH, +) + + +def execute_command(cmd): + """ + Execute a command in the shell. + """ + + logger = flytekit.current_context().logging + + process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + logger.info(f"cmd: {cmd}") + stdout, stderr = process.communicate() + if process.returncode != EXIT_CODE_SUCCESS: + raise RuntimeError(f"Command {cmd} failed with error: {stderr}") + logger.info(f"stdout: {stdout}") + logger.info(f"stderr: {stderr}") + + +def exit_handler( + child_process: multiprocessing.Process, + task_function, + args, + kwargs, + max_idle_seconds: int = 180, + post_execute: Optional[Callable] = None, +): + """ + 1. Check the modified time of ~/.local/share/code-server/heartbeat. + If it is older than max_idle_second seconds, kill the container. + Otherwise, check again every HEARTBEAT_CHECK_SECONDS. + 2. Wait for user to resume the task. If resume_task is set, terminate the VSCode server, reload the task function, and run it with the input of the task. + + Args: + child_process (multiprocessing.Process, optional): The process to be terminated. + max_idle_seconds (int, optional): The duration in seconds to live after no activity detected. + post_execute (function, optional): The function to be executed before the vscode is self-terminated. + """ + + def terminate_process(): + if post_execute is not None: + post_execute() + logger.info("Post execute function executed successfully!") + child_process.terminate() + child_process.join() + + logger = flytekit.current_context().logging + start_time = time.time() + delta = 0 + + while not resume_task.is_set(): + if not os.path.exists(HEARTBEAT_PATH): + delta = time.time() - start_time + logger.info(f"Code server has not been connected since {delta} seconds ago.") + logger.info("Please open the browser to connect to the running server.") + else: + delta = time.time() - os.path.getmtime(HEARTBEAT_PATH) + logger.info(f"The latest activity on code server is {delta} seconds ago.") + + # If the time from last connection is longer than max idle seconds, terminate the vscode server. + if delta > max_idle_seconds: + logger.info(f"VSCode server is idle for more than {max_idle_seconds} seconds. Terminating...") + terminate_process() + sys.exit() + + # Wait for HEARTBEAT_CHECK_SECONDS seconds, but return immediately when resume_task is set. + resume_task.wait(timeout=HEARTBEAT_CHECK_SECONDS) + + # User has resumed the task. + terminate_process() + + # Reload the task function since it may be modified. + task_function_source_path = FlyteContextManager.current_context().user_space_params.TASK_FUNCTION_SOURCE_PATH + task_function = getattr( + load_module_from_path(task_function.__module__, task_function_source_path), task_function.__name__ + ) + + # Get the actual function from the task. + while hasattr(task_function, "__wrapped__"): + if isinstance(task_function, vscode): + task_function = task_function.__wrapped__ + break + task_function = task_function.__wrapped__ + return task_function(*args, **kwargs) + + +def download_file(url, target_dir: Optional[str] = "."): + """ + Download a file from a given URL using fsspec. + + Args: + url (str): The URL of the file to download. + target_dir (str, optional): The directory where the file should be saved. Defaults to current directory. + + Returns: + str: The path to the downloaded file. + """ + logger = flytekit.current_context().logging + if not url.startswith("http"): + raise ValueError(f"URL {url} is not valid. Only http/https is supported.") + + # Derive the local filename from the URL + local_file_name = os.path.join(target_dir, os.path.basename(url)) + + fs = fsspec.filesystem("http") + + # Use fsspec to get the remote file and save it locally + logger.info(f"Downloading {url}... to {os.path.abspath(local_file_name)}") + fs.get(url, local_file_name) + logger.info("File downloaded successfully!") + + return local_file_name + + +def get_code_server_info(code_server_info_dict: dict) -> str: + """ + Returns the code server information based on the system's architecture. + + This function checks the system's architecture and returns the corresponding + code server information from the provided dictionary. The function currently + supports AMD64 and ARM64 architectures. + + Args: + code_server_info_dict (dict): A dictionary containing code server information. + The keys should be the architecture type ('amd64' or 'arm64') and the values + should be the corresponding code server information. + + Returns: + str: The code server information corresponding to the system's architecture. + + Raises: + ValueError: If the system's architecture is not AMD64 or ARM64. + """ + logger = flytekit.current_context().logging + machine_info = platform.machine() + logger.info(f"machine type: {machine_info}") + + if "aarch64" == machine_info: + return code_server_info_dict.get("arm64", None) + elif "x86_64" == machine_info: + return code_server_info_dict.get("amd64", None) + else: + raise ValueError( + "Automatic download is only supported on AMD64 and ARM64 architectures. If you are using a different architecture, please visit the code-server official website to manually download the appropriate version for your image." + ) + + +def get_installed_extensions() -> List[str]: + """ + Get the list of installed extensions. + + Returns: + List[str]: The list of installed extensions. + """ + logger = flytekit.current_context().logging + + installed_extensions = subprocess.run(["code-server", "--list-extensions"], capture_output=True, text=True) + if installed_extensions.returncode != EXIT_CODE_SUCCESS: + logger.info(f"Command code-server --list-extensions failed with error: {installed_extensions.stderr}") + return [] + + return installed_extensions.stdout.splitlines() + + +def is_extension_installed(extension: str, installed_extensions: List[str]) -> bool: + return any(installed_extension in extension for installed_extension in installed_extensions) + + +def download_vscode(config: VscodeConfig): + """ + Download vscode server and extension from remote to local and add the directory of binary executable to $PATH. + + Args: + config (VscodeConfig): VSCode config contains default URLs of the VSCode server and extension remote paths. + """ + logger = flytekit.current_context().logging + + # If the code server already exists in the container, skip downloading + executable_path = shutil.which(EXECUTABLE_NAME) + if executable_path is not None: + logger.info(f"Code server binary already exists at {executable_path}") + logger.info("Skipping downloading code server...") + else: + logger.info("Code server is not in $PATH, start downloading code server...") + # Create DOWNLOAD_DIR if not exist + logger.info(f"DOWNLOAD_DIR: {DOWNLOAD_DIR}") + os.makedirs(DOWNLOAD_DIR, exist_ok=True) + + logger.info(f"Start downloading files to {DOWNLOAD_DIR}") + # Download remote file to local + code_server_remote_path = get_code_server_info(config.code_server_remote_paths) + code_server_tar_path = download_file(code_server_remote_path, DOWNLOAD_DIR) + + # Extract the tarball + with tarfile.open(code_server_tar_path, "r:gz") as tar: + tar.extractall(path=DOWNLOAD_DIR) + + code_server_dir_name = get_code_server_info(config.code_server_dir_names) + code_server_bin_dir = os.path.join(DOWNLOAD_DIR, code_server_dir_name, "bin") + + # Add the directory of code-server binary to $PATH + os.environ["PATH"] = code_server_bin_dir + os.pathsep + os.environ["PATH"] + + # If the extension already exists in the container, skip downloading + installed_extensions = get_installed_extensions() + extension_paths = [] + for extension in config.extension_remote_paths: + if not is_extension_installed(extension, installed_extensions): + file_path = download_file(extension, DOWNLOAD_DIR) + extension_paths.append(file_path) + + for p in extension_paths: + logger.info(f"Execute extension installation command to install extension {p}") + execute_command(f"code-server --install-extension {p}") + + +def prepare_interactive_python(task_function): + """ + 1. Copy the original task file to the context working directory. This ensures that the inputs.pb can be loaded, as loading requires the original task interface. + By doing so, even if users change the task interface in their code, we can use the copied task file to load the inputs as native Python objects. + 2. Generate a Python script and a launch.json for users to debug interactively. + + Args: + task_function (function): User's task function. + """ + + task_function_source_path = FlyteContextManager.current_context().user_space_params.TASK_FUNCTION_SOURCE_PATH + context_working_dir = FlyteContextManager.current_context().execution_state.working_dir + + # Copy the user's Python file to the working directory. + shutil.copy( + task_function_source_path, + os.path.join(context_working_dir, os.path.basename(task_function_source_path)), + ) + + # Generate a Python script + task_module_name, task_name = task_function.__module__, task_function.__name__ + python_script = f"""# This file is auto-generated by flyteinteractive + +from {task_module_name} import {task_name} +from flytekitplugins.flyteinteractive import get_task_inputs + +if __name__ == "__main__": + inputs = get_task_inputs( + task_module_name="{task_module_name}", + task_name="{task_name}", + context_working_dir="{context_working_dir}", + ) + # You can modify the inputs! Ex: inputs['a'] = 5 + print({task_name}(**inputs)) +""" + + task_function_source_dir = os.path.dirname(task_function_source_path) + with open(os.path.join(task_function_source_dir, INTERACTIVE_DEBUGGING_FILE_NAME), "w") as file: + file.write(python_script) + + +def prepare_resume_task_python(): + """ + Generate a Python script for users to resume the task. + """ + + python_script = f"""import os +import signal + +if __name__ == "__main__": + print("Terminating server and resuming task.") + answer = input("This operation will kill the server. All unsaved data will be lost, and you will no longer be able to connect to it. Do you really want to terminate? (Y/N): ").strip().upper() + if answer == 'Y': + PID = {os.getpid()} + os.kill(PID, signal.SIGTERM) + print(f"The server has been terminated and the task has been resumed.") + else: + print("Operation canceled.") +""" + + task_function_source_dir = os.path.dirname( + FlyteContextManager.current_context().user_space_params.TASK_FUNCTION_SOURCE_PATH + ) + with open(os.path.join(task_function_source_dir, RESUME_TASK_FILE_NAME), "w") as file: + file.write(python_script) + + +def prepare_launch_json(): + """ + Generate the launch.json for users to easily launch interactive debugging and task resumption. + """ + + task_function_source_dir = os.path.dirname( + FlyteContextManager.current_context().user_space_params.TASK_FUNCTION_SOURCE_PATH + ) + launch_json = { + "version": "0.2.0", + "configurations": [ + { + "name": "Interactive Debugging", + "type": "python", + "request": "launch", + "program": os.path.join(task_function_source_dir, INTERACTIVE_DEBUGGING_FILE_NAME), + "console": "integratedTerminal", + "justMyCode": True, + }, + { + "name": "Resume Task", + "type": "python", + "request": "launch", + "program": os.path.join(task_function_source_dir, RESUME_TASK_FILE_NAME), + "console": "integratedTerminal", + "justMyCode": True, + }, + ], + } + + vscode_directory = os.path.join(task_function_source_dir, ".vscode") + if not os.path.exists(vscode_directory): + os.makedirs(vscode_directory) + + with open(os.path.join(vscode_directory, "launch.json"), "w") as file: + json.dump(launch_json, file, indent=4) + + +def resume_task_handler(signum, frame): + """ + The signal handler for task resumption. + """ + resume_task.set() + + +resume_task = Event() +VSCODE_TYPE_VALUE = "vscode" + + +class vscode(ClassDecorator): + def __init__( + self, + task_function: Optional[Callable] = None, + max_idle_seconds: Optional[int] = MAX_IDLE_SECONDS, + port: int = 8080, + enable: bool = True, + run_task_first: bool = False, + pre_execute: Optional[Callable] = None, + post_execute: Optional[Callable] = None, + config: Optional[VscodeConfig] = None, + ): + """ + vscode decorator modifies a container to run a VSCode server: + 1. Overrides the user function with a VSCode setup function. + 2. Download vscode server and extension from remote to local. + 3. Prepare the interactive debugging Python script and launch.json. + 4. Prepare task resumption script. + 5. Launches and monitors the VSCode server. + 6. Register signal handler for task resumption. + 7. Terminates if the server is idle for a set duration or user trigger task resumption. + + Args: + task_function (function, optional): The user function to be decorated. Defaults to None. + max_idle_seconds (int, optional): The duration in seconds to live after no activity detected. + port (int, optional): The port to be used by the VSCode server. Defaults to 8080. + enable (bool, optional): Whether to enable the VSCode decorator. Defaults to True. + run_task_first (bool, optional): Executes the user's task first when True. Launches the VSCode server only if the user's task fails. Defaults to False. + pre_execute (function, optional): The function to be executed before the vscode setup function. + post_execute (function, optional): The function to be executed before the vscode is self-terminated. + config (VscodeConfig, optional): VSCode config contains default URLs of the VSCode server and extension remote paths. + """ + + # these names cannot conflict with base_task method or member variables + # otherwise, the base_task method will be overwritten + # for example, base_task also has "pre_execute", so we name it "_pre_execute" here + self.max_idle_seconds = max_idle_seconds + self.port = port + self.enable = enable + self.run_task_first = run_task_first + self._pre_execute = pre_execute + self._post_execute = post_execute + + if config is None: + config = VscodeConfig() + self._config = config + + # arguments are required to be passed in order to access from _wrap_call + super().__init__( + task_function, + max_idle_seconds=max_idle_seconds, + port=port, + enable=enable, + run_task_first=run_task_first, + pre_execute=pre_execute, + post_execute=post_execute, + config=config, + ) + + def execute(self, *args, **kwargs): + ctx = FlyteContextManager.current_context() + logger = flytekit.current_context().logging + ctx.user_space_params.builder().add_attr( + TASK_FUNCTION_SOURCE_PATH, inspect.getsourcefile(self.task_function) + ).build() + + # 1. If the decorator is disabled, we don't launch the VSCode server. + # 2. When user use pyflyte run or python to execute the task, we don't launch the VSCode server. + # Only when user use pyflyte run --remote to submit the task to cluster, we launch the VSCode server. + if not self.enable or ctx.execution_state.is_local_execution(): + return self.task_function(*args, **kwargs) + + if self.run_task_first: + logger.info("Run user's task first") + try: + return self.task_function(*args, **kwargs) + except Exception as e: + logger.error(f"Task Error: {e}") + logger.info("Launching VSCode server") + + # 0. Executes the pre_execute function if provided. + if self._pre_execute is not None: + self._pre_execute() + logger.info("Pre execute function executed successfully!") + + # 1. Downloads the VSCode server from Internet to local. + download_vscode(self._config) + + # 2. Prepare the interactive debugging Python script and launch.json. + prepare_interactive_python(self.task_function) # type: ignore + + # 3. Prepare the task resumption Python script. + prepare_resume_task_python() + + # 4. Prepare the launch.json + prepare_launch_json() + + # 5. Launches and monitors the VSCode server. + # Run the function in the background. + # Make the task function's source file directory the default directory. + task_function_source_dir = os.path.dirname( + FlyteContextManager.current_context().user_space_params.TASK_FUNCTION_SOURCE_PATH + ) + child_process = multiprocessing.Process( + target=execute_command, + kwargs={ + "cmd": f"code-server --bind-addr 0.0.0.0:{self.port} --disable-workspace-trust --auth none {task_function_source_dir}" + }, + ) + child_process.start() + + # 6. Register the signal handler for task resumption. This should be after creating the subprocess so that the subprocess won't inherit the signal handler. + signal.signal(signal.SIGTERM, resume_task_handler) + + return exit_handler( + child_process=child_process, + task_function=self.task_function, + args=args, + kwargs=kwargs, + max_idle_seconds=self.max_idle_seconds, + post_execute=self._post_execute, + ) + + def get_extra_config(self): + return {self.LINK_TYPE_KEY: VSCODE_TYPE_VALUE, self.PORT_KEY: str(self.port)} diff --git a/flytekit/plugins/flytekit-flyteinteractive/setup.py b/flytekit/plugins/flytekit-flyteinteractive/setup.py new file mode 100644 index 0000000000..3ca87c9516 --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup + +PLUGIN_NAME = "flyteinteractive" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = ["flytekit>=1.1.0b0,<2.0.0", "jupyter"] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="This package holds the flyteinteractive plugins for flytekit", + namespace_packages=["flytekitplugins"], + packages=[ + f"flytekitplugins.{PLUGIN_NAME}", + f"flytekitplugins.{PLUGIN_NAME}.vscode_lib", + f"flytekitplugins.{PLUGIN_NAME}.jupyter_lib", + ], + 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/flytekit/plugins/flytekit-flyteinteractive/tests/__init__.py b/flytekit/plugins/flytekit-flyteinteractive/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-flyteinteractive/tests/test_flyin_plugin.py b/flytekit/plugins/flytekit-flyteinteractive/tests/test_flyin_plugin.py new file mode 100644 index 0000000000..45e6e5e42e --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/tests/test_flyin_plugin.py @@ -0,0 +1,423 @@ +from collections import OrderedDict + +import mock +import pytest +from flytekitplugins.flyteinteractive import ( + CODE_TOGETHER_CONFIG, + CODE_TOGETHER_EXTENSION, + COPILOT_CONFIG, + COPILOT_EXTENSION, + DEFAULT_CODE_SERVER_DIR_NAMES, + DEFAULT_CODE_SERVER_EXTENSIONS, + DEFAULT_CODE_SERVER_REMOTE_PATHS, + VIM_CONFIG, + VIM_EXTENSION, + VscodeConfig, + jupyter, + vscode, +) +from flytekitplugins.flyteinteractive.vscode_lib.constants import EXIT_CODE_SUCCESS +from flytekitplugins.flyteinteractive.vscode_lib.decorator import ( + get_code_server_info, + get_installed_extensions, + is_extension_installed, +) + +from flytekit import task, workflow +from flytekit.configuration import Image, ImageConfig, SerializationSettings +from flytekit.core.context_manager import ExecutionState +from flytekit.tools.translator import get_serializable_task + + +@pytest.fixture +def mock_local_execution(): + with mock.patch.object(ExecutionState, "is_local_execution", return_value=True) as mock_func: + yield mock_func + + +@pytest.fixture +def mock_remote_execution(): + with mock.patch.object(ExecutionState, "is_local_execution", return_value=False) as mock_func: + yield mock_func + + +@pytest.fixture +def mock_code_server_info_dict(): + return {"arm64": "Arm server info", "amd64": "AMD server info"} + + +@pytest.fixture +def vscode_patches(): + with mock.patch("multiprocessing.Process") as mock_process, mock.patch( + "flytekitplugins.flyteinteractive.vscode_lib.decorator.prepare_interactive_python" + ) as mock_prepare_interactive_python, mock.patch( + "flytekitplugins.flyteinteractive.vscode_lib.decorator.exit_handler" + ) as mock_exit_handler, mock.patch( + "flytekitplugins.flyteinteractive.vscode_lib.decorator.download_vscode" + ) as mock_download_vscode, mock.patch("signal.signal") as mock_signal, mock.patch( + "flytekitplugins.flyteinteractive.vscode_lib.decorator.prepare_resume_task_python" + ) as mock_prepare_resume_task_python, mock.patch( + "flytekitplugins.flyteinteractive.vscode_lib.decorator.prepare_launch_json" + ) as mock_prepare_launch_json: + yield ( + mock_process, + mock_prepare_interactive_python, + mock_exit_handler, + mock_download_vscode, + mock_signal, + mock_prepare_resume_task_python, + mock_prepare_launch_json, + ) + + +def test_vscode_remote_execution(vscode_patches, mock_remote_execution): + ( + mock_process, + mock_prepare_interactive_python, + mock_exit_handler, + mock_download_vscode, + mock_signal, + mock_prepare_resume_task_python, + mock_prepare_launch_json, + ) = vscode_patches + + @task + @vscode + def t(): + return + + @workflow + def wf(): + t() + + wf() + mock_download_vscode.assert_called_once() + mock_process.assert_called_once() + mock_exit_handler.assert_called_once() + mock_prepare_interactive_python.assert_called_once() + mock_signal.assert_called_once() + mock_prepare_resume_task_python.assert_called_once() + mock_prepare_launch_json.assert_called_once() + + +def test_vscode_remote_execution_but_disable(vscode_patches, mock_remote_execution): + ( + mock_process, + mock_prepare_interactive_python, + mock_exit_handler, + mock_download_vscode, + mock_signal, + mock_prepare_resume_task_python, + mock_prepare_launch_json, + ) = vscode_patches + + @task + @vscode(enable=False) + def t(): + return + + @workflow + def wf(): + t() + + wf() + mock_download_vscode.assert_not_called() + mock_process.assert_not_called() + mock_exit_handler.assert_not_called() + mock_prepare_interactive_python.assert_not_called() + mock_signal.assert_not_called() + mock_prepare_resume_task_python.assert_not_called() + mock_prepare_launch_json.assert_not_called() + + +def test_vscode_local_execution(vscode_patches, mock_local_execution): + ( + mock_process, + mock_prepare_interactive_python, + mock_exit_handler, + mock_download_vscode, + mock_signal, + mock_prepare_resume_task_python, + mock_prepare_launch_json, + ) = vscode_patches + + @task + @vscode + def t(): + return + + @workflow + def wf(): + t() + + wf() + mock_download_vscode.assert_not_called() + mock_process.assert_not_called() + mock_exit_handler.assert_not_called() + mock_prepare_interactive_python.assert_not_called() + mock_signal.assert_not_called() + mock_prepare_resume_task_python.assert_not_called() + mock_prepare_launch_json.assert_not_called() + + +def test_vscode_run_task_first_succeed(mock_remote_execution): + @task + @vscode(run_task_first=True) + def t(a: int, b: int) -> int: + return a + b + + @workflow + def wf(a: int, b: int) -> int: + out = t(a=a, b=b) + return out + + res = wf(a=10, b=5) + assert res == 15 + + +def test_vscode_run_task_first_fail(vscode_patches, mock_remote_execution): + ( + mock_process, + mock_prepare_interactive_python, + mock_exit_handler, + mock_download_vscode, + mock_signal, + mock_prepare_resume_task_python, + mock_prepare_launch_json, + ) = vscode_patches + + @task + @vscode + def t(a: int, b: int): + dummy = a // b # noqa: F841 + return + + @workflow + def wf(a: int, b: int): + t(a=a, b=b) + + wf(a=10, b=0) + mock_download_vscode.assert_called_once() + mock_process.assert_called_once() + mock_exit_handler.assert_called_once() + mock_prepare_interactive_python.assert_called_once() + mock_signal.assert_called_once() + mock_prepare_resume_task_python.assert_called_once() + mock_prepare_launch_json.assert_called_once() + + +@mock.patch("flytekitplugins.flyteinteractive.jupyter_lib.decorator.subprocess.Popen") +@mock.patch("flytekitplugins.flyteinteractive.jupyter_lib.decorator.sys.exit") +def test_jupyter(mock_exit, mock_popen): + @task + @jupyter + def t(): + return + + @workflow + def wf(): + t() + + wf() + mock_popen.assert_called_once() + mock_exit.assert_called_once() + + +def test_is_extension_installed(): + installed_extensions = [ + "ms-python.python", + "ms-toolsai.jupyter", + "ms-toolsai.jupyter-keymap", + "ms-toolsai.jupyter-renderers", + "ms-toolsai.vscode-jupyter-cell-tags", + "ms-toolsai.vscode-jupyter-slideshow", + ] + config = VscodeConfig() + for extension in config.extension_remote_paths: + assert is_extension_installed(extension, installed_extensions) + + +def test_vscode_config(): + config = VscodeConfig() + assert config.code_server_remote_paths == DEFAULT_CODE_SERVER_REMOTE_PATHS + assert config.code_server_dir_names == DEFAULT_CODE_SERVER_DIR_NAMES + assert config.extension_remote_paths == DEFAULT_CODE_SERVER_EXTENSIONS + + code_together_config = CODE_TOGETHER_CONFIG + assert code_together_config.code_server_remote_paths == DEFAULT_CODE_SERVER_REMOTE_PATHS + assert code_together_config.code_server_dir_names == DEFAULT_CODE_SERVER_DIR_NAMES + assert code_together_config.extension_remote_paths == DEFAULT_CODE_SERVER_EXTENSIONS + [CODE_TOGETHER_EXTENSION] + + copilot_config = COPILOT_CONFIG + assert copilot_config.code_server_remote_paths == DEFAULT_CODE_SERVER_REMOTE_PATHS + assert copilot_config.code_server_dir_names == DEFAULT_CODE_SERVER_DIR_NAMES + assert copilot_config.extension_remote_paths == DEFAULT_CODE_SERVER_EXTENSIONS + [COPILOT_EXTENSION] + + vim_config = VIM_CONFIG + assert vim_config.code_server_remote_paths == DEFAULT_CODE_SERVER_REMOTE_PATHS + assert vim_config.code_server_dir_names == DEFAULT_CODE_SERVER_DIR_NAMES + assert vim_config.extension_remote_paths == DEFAULT_CODE_SERVER_EXTENSIONS + [VIM_EXTENSION] + + all_extensions_config = VscodeConfig() + all_extensions_config.add_extensions([CODE_TOGETHER_EXTENSION, COPILOT_EXTENSION, VIM_EXTENSION]) + assert CODE_TOGETHER_EXTENSION in all_extensions_config.extension_remote_paths + assert COPILOT_EXTENSION in all_extensions_config.extension_remote_paths + assert VIM_EXTENSION in all_extensions_config.extension_remote_paths + + +def test_vscode_config_add_extensions(): + additional_extensions = [COPILOT_EXTENSION, VIM_EXTENSION, CODE_TOGETHER_EXTENSION] + + config = VscodeConfig() + config.add_extensions(additional_extensions) + + for extension in additional_extensions: + assert extension in config.extension_remote_paths + + additional_extension = "test_str_extension" + config.add_extensions(additional_extension) + assert additional_extension in config.extension_remote_paths + + +def test_vscode_with_args(vscode_patches, mock_remote_execution): + ( + mock_process, + mock_prepare_interactive_python, + mock_exit_handler, + mock_download_vscode, + mock_signal, + mock_prepare_resume_task_python, + mock_prepare_launch_json, + ) = vscode_patches + + @task + @vscode + def t(): + return + + @workflow + def wf(): + t() + + wf() + + mock_download_vscode.assert_called_once() + mock_process.assert_called_once() + mock_exit_handler.assert_called_once() + mock_prepare_interactive_python.assert_called_once() + mock_signal.assert_called_once() + mock_prepare_resume_task_python.assert_called_once() + mock_prepare_launch_json.assert_called_once() + + +def test_vscode_extra_config(mock_remote_execution): + @vscode( + max_idle_seconds=100, + port=8081, + enable=True, + pre_execute=None, + post_execute=None, + config=None, + ) + def t(): + return + + assert t.get_extra_config()["link_type"] == "vscode" + assert t.get_extra_config()["port"] == "8081" + + +def test_serialize_vscode(mock_remote_execution): + @task + @vscode( + max_idle_seconds=100, + port=8081, + enable=True, + pre_execute=None, + post_execute=None, + config=None, + ) + def t(): + return + + 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 + ) + + serialized_task = get_serializable_task(OrderedDict(), default_serialization_settings, t) + assert serialized_task.template.config == {"link_type": "vscode", "port": "8081"} + + +@mock.patch("platform.machine", return_value="aarch64") +def test_arm_platform(mock_machine, mock_code_server_info_dict): + assert get_code_server_info(mock_code_server_info_dict) == "Arm server info" + + +@mock.patch("platform.machine", return_value="x86_64") +def test_amd_platform(mock_machine, mock_code_server_info_dict): + assert get_code_server_info(mock_code_server_info_dict) == "AMD server info" + + +@mock.patch("platform.machine", return_value="Unsupported machine info") +def test_platform_unsupported(mock_machine, mock_code_server_info_dict): + with pytest.raises( + ValueError, + match="Automatic download is only supported on AMD64 and ARM64 architectures. If you are using a different architecture, please visit the code-server official website to manually download the appropriate version for your image.", + ): + get_code_server_info(mock_code_server_info_dict) + + +@mock.patch("subprocess.run") +def test_get_installed_extensions_succeeded(mock_run): + # Set up the mock process + mock_process = mock.Mock() + mock_process.returncode = EXIT_CODE_SUCCESS + mock_process.stdout = ( + "ms-python.python\n" + "ms-toolsai.jupyter\n" + "ms-toolsai.jupyter-keymap\n" + "ms-toolsai.jupyter-renderers\n" + "ms-toolsai.vscode-jupyter-cell-tags\n" + "ms-toolsai.vscode-jupyter-slideshow\n" + ) + mock_run.return_value = mock_process + + installed_extensions = get_installed_extensions() + + # Verify the correct command was called + mock_run.assert_called_once_with(["code-server", "--list-extensions"], capture_output=True, text=True) + + # Assert that the output matches the expected list of extensions + expected_extensions = [ + "ms-python.python", + "ms-toolsai.jupyter", + "ms-toolsai.jupyter-keymap", + "ms-toolsai.jupyter-renderers", + "ms-toolsai.vscode-jupyter-cell-tags", + "ms-toolsai.vscode-jupyter-slideshow", + ] + assert installed_extensions == expected_extensions + + +@mock.patch("subprocess.run") +def test_get_installed_extensions_failed(mock_run): + # Set up the mock process + mock_process = mock.Mock() + mock_process.returncode = 1 + mock_process.stdout = ( + "ms-python.python\n" + "ms-toolsai.jupyter\n" + "ms-toolsai.jupyter-keymap\n" + "ms-toolsai.jupyter-renderers\n" + "ms-toolsai.vscode-jupyter-cell-tags\n" + "ms-toolsai.vscode-jupyter-slideshow\n" + ) + mock_run.return_value = mock_process + + installed_extensions = get_installed_extensions() + + mock_run.assert_called_once_with(["code-server", "--list-extensions"], capture_output=True, text=True) + + expected_extensions = [] + assert installed_extensions == expected_extensions diff --git a/flytekit/plugins/flytekit-flyteinteractive/tests/test_utils.py b/flytekit/plugins/flytekit-flyteinteractive/tests/test_utils.py new file mode 100644 index 0000000000..d458b540fd --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/tests/test_utils.py @@ -0,0 +1,20 @@ +import os + +from flytekitplugins.flyteinteractive import get_task_inputs +from flytekitplugins.flyteinteractive.utils import load_module_from_path + + +def test_load_module_from_path(): + module_name = "task" + module_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testdata", "task.py") + task_name = "t1" + task_module = load_module_from_path(module_name, module_path) + assert hasattr(task_module, task_name) + task_def = getattr(task_module, task_name) + assert task_def(a=6, b=3) == 2 + + +def test_get_task_inputs(): + test_working_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "testdata") + native_inputs = get_task_inputs("task", "t1", test_working_dir) + assert native_inputs == {"a": 30, "b": 0} diff --git a/flytekit/plugins/flytekit-flyteinteractive/tests/testdata/inputs.pb b/flytekit/plugins/flytekit-flyteinteractive/tests/testdata/inputs.pb new file mode 100644 index 0000000000000000000000000000000000000000..3cb31285034b048b3e34fe2d02a518c87a36513b GIT binary patch literal 26 acmd<$=3-0|V&h`rV&Y)n0&)_e9610MD*=`O literal 0 HcmV?d00001 diff --git a/flytekit/plugins/flytekit-flyteinteractive/tests/testdata/task.py b/flytekit/plugins/flytekit-flyteinteractive/tests/testdata/task.py new file mode 100644 index 0000000000..821a06e2c4 --- /dev/null +++ b/flytekit/plugins/flytekit-flyteinteractive/tests/testdata/task.py @@ -0,0 +1,9 @@ +from flytekitplugins.flyteinteractive import vscode + +from flytekit import task + + +@task() +@vscode(run_task_first=True) +def t1(a: int, b: int) -> int: + return a // b diff --git a/flytekit/plugins/flytekit-greatexpectations/README.md b/flytekit/plugins/flytekit-greatexpectations/README.md new file mode 100644 index 0000000000..f23ba44226 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/README.md @@ -0,0 +1,76 @@ +# Flytekit Great Expectations Plugin + +Great Expectations helps enforce data quality. The plugin supports the usage of Great Expectations as task and type. + +To install the plugin, run the following command: + +```bash +pip install flytekitplugins-great-expectations +``` + +## Task Example +```python +import os + +import pandas as pd +from flytekit import Resources, kwtypes, task, workflow +from flytekitplugins.great_expectations import BatchRequestConfig, GreatExpectationsTask + +simple_task_object = GreatExpectationsTask( + name="great_expectations_task_simple", + datasource_name="data", + inputs=kwtypes(dataset=str), + expectation_suite_name="test.demo", + data_connector_name="data_example_data_connector", + context_root_dir="great_expectations", +) + +@task(limits=Resources(mem="500Mi")) +def simple_task(csv_file: str) -> int: + result = simple_task_object(dataset=csv_file) + df = pd.read_csv(os.path.join("greatexpectations", "data", csv_file)) + return df.shape[0] + +@workflow +def simple_wf(dataset: str = "yellow_tripdata_sample_2019-01.csv") -> int: + return simple_task(csv_file=dataset) +``` + +## Type Example +```python +from flytekit import workflow +from flytekitplugins.great_expectations import ( + BatchRequestConfig, + GreatExpectationsFlyteConfig, + GreatExpectationsType, +) + +def simple_task( + directory: GreatExpectationsType[ + str, + GreatExpectationsFlyteConfig( + datasource_name="data", + expectation_suite_name="test.demo", + data_connector_name="my_data_connector", + batch_request_config=BatchRequestConfig( + data_connector_query={ + "batch_filter_parameters": { + "year": "2019", + "month": "01", + }, + "limit": 10, + }, + ), + context_root_dir="great_expectations", + ), + ] +) -> str: + return f"Validation works for {directory}!" + + +@workflow +def simple_wf(directory: str = "my_assets") -> str: + return simple_task(directory=directory) +``` + +[More examples](https://docs.flyte.org/projects/cookbook/en/latest/auto/integrations/flytekit_plugins/greatexpectations/index.html) can be found in the documentation. diff --git a/flytekit/plugins/flytekit-greatexpectations/dev-requirements.in b/flytekit/plugins/flytekit-greatexpectations/dev-requirements.in new file mode 100644 index 0000000000..35fcaf1b07 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/dev-requirements.in @@ -0,0 +1 @@ +-e file:../../.#egg=flytekitplugins-spark&subdirectory=plugins/flytekit-spark diff --git a/flytekit/plugins/flytekit-greatexpectations/dev-requirements.txt b/flytekit/plugins/flytekit-greatexpectations/dev-requirements.txt new file mode 100644 index 0000000000..45ac83dc16 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/dev-requirements.txt @@ -0,0 +1,341 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# pip-compile dev-requirements.in +# +-e file:../../.#egg=flytekitplugins-spark&subdirectory=plugins/flytekit-spark + # via -r dev-requirements.in +adlfs==2023.9.0 + # via flytekit +aiobotocore==2.5.4 + # via s3fs +aiohttp==3.9.3 + # via + # adlfs + # aiobotocore + # flytekitplugins-spark + # gcsfs + # s3fs +aioitertools==0.11.0 + # via aiobotocore +aiosignal==1.3.1 + # via aiohttp +arrow==1.3.0 + # via cookiecutter +async-timeout==4.0.3 + # via aiohttp +attrs==23.2.0 + # via aiohttp +azure-core==1.30.0 + # via + # adlfs + # azure-identity + # azure-storage-blob +azure-datalake-store==0.0.53 + # via adlfs +azure-identity==1.15.0 + # via adlfs +azure-storage-blob==12.19.0 + # via adlfs +binaryornot==0.4.4 + # via cookiecutter +botocore==1.31.17 + # via aiobotocore +cachetools==5.3.2 + # via google-auth +certifi==2024.2.2 + # via + # kubernetes + # requests +cffi==1.16.0 + # via + # azure-datalake-store + # cryptography +chardet==5.2.0 + # via binaryornot +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # cookiecutter + # flytekit + # rich-click +cloudpickle==3.0.0 + # via flytekit +cookiecutter==2.5.0 + # via flytekit +croniter==2.0.1 + # via flytekit +cryptography==42.0.2 + # via + # azure-identity + # azure-storage-blob + # msal + # pyjwt +dataclasses-json==0.5.9 + # via flytekit +decorator==5.1.1 + # via gcsfs +diskcache==5.6.3 + # via flytekit +docker==6.1.3 + # via flytekit +docstring-parser==0.15 + # via flytekit +flyteidl==1.10.6 + # via + # flytekit + # flytekitplugins-spark +flytekit==1.10.3 + # via flytekitplugins-spark +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +fsspec==2023.9.2 + # via + # adlfs + # flytekit + # gcsfs + # s3fs +gcsfs==2023.9.2 + # via flytekit +google-api-core==2.16.2 + # via + # google-cloud-core + # google-cloud-storage +google-auth==2.27.0 + # via + # gcsfs + # google-api-core + # google-auth-oauthlib + # google-cloud-core + # google-cloud-storage + # kubernetes +google-auth-oauthlib==1.2.0 + # via gcsfs +google-cloud-core==2.4.1 + # via google-cloud-storage +google-cloud-storage==2.14.0 + # via gcsfs +google-crc32c==1.5.0 + # via + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 + # via google-cloud-storage +googleapis-common-protos==1.62.0 + # via + # flyteidl + # flytekit + # google-api-core + # grpcio-status +grpcio==1.60.1 + # via + # flytekit + # grpcio-status +grpcio-status==1.60.1 + # via flytekit +idna==3.6 + # via + # requests + # yarl +importlib-metadata==7.0.1 + # via + # flytekit + # keyring +importlib-resources==6.1.1 + # via keyring +isodate==0.6.1 + # via azure-storage-blob +jaraco-classes==3.3.0 + # via keyring +jinja2==3.1.3 + # via cookiecutter +jmespath==1.0.1 + # via botocore +joblib==1.3.2 + # via flytekit +jsonpickle==3.0.2 + # via flytekit +keyring==24.3.0 + # via flytekit +kubernetes==29.0.0 + # via flytekit +markdown-it-py==3.0.0 + # via rich +markupsafe==2.1.5 + # via jinja2 +marshmallow==3.20.2 + # via + # dataclasses-json + # marshmallow-enum + # marshmallow-jsonschema +marshmallow-enum==1.5.1 + # via + # dataclasses-json + # flytekit +marshmallow-jsonschema==0.13.0 + # via flytekit +mashumaro==3.12 + # via flytekit +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.2.0 + # via jaraco-classes +msal==1.26.0 + # via + # azure-datalake-store + # azure-identity + # msal-extensions +msal-extensions==1.1.0 + # via azure-identity +multidict==6.0.5 + # via + # aiohttp + # yarl +mypy-extensions==1.0.0 + # via typing-inspect +numpy==1.24.4 + # via + # pandas + # pyarrow +oauthlib==3.2.2 + # via + # kubernetes + # requests-oauthlib +packaging==23.2 + # via + # docker + # marshmallow + # msal-extensions +pandas==2.0.3 + # via flytekitplugins-spark +portalocker==2.8.2 + # via msal-extensions +protobuf==4.24.4 + # via + # flyteidl + # flytekit + # google-api-core + # googleapis-common-protos + # grpcio-status + # protoc-gen-swagger +protoc-gen-swagger==0.1.0 + # via flyteidl +py4j==0.10.9.7 + # via pyspark +pyarrow==15.0.0 + # via flytekit +pyasn1==0.5.1 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.3.0 + # via google-auth +pycparser==2.21 + # via cffi +pygments==2.17.2 + # via rich +pyjwt[crypto]==2.8.0 + # via + # msal + # pyjwt +pyspark==3.5.0 + # via flytekitplugins-spark +python-dateutil==2.8.2 + # via + # arrow + # botocore + # croniter + # kubernetes + # pandas +python-json-logger==2.0.7 + # via flytekit +python-slugify==8.0.3 + # via cookiecutter +pytimeparse==1.1.8 + # via flytekit +pytz==2024.1 + # via + # croniter + # pandas +pyyaml==6.0.1 + # via + # cookiecutter + # flytekit + # kubernetes +requests==2.31.0 + # via + # azure-core + # azure-datalake-store + # cookiecutter + # docker + # flytekit + # gcsfs + # google-api-core + # google-cloud-storage + # kubernetes + # msal + # requests-oauthlib +requests-oauthlib==1.3.1 + # via + # google-auth-oauthlib + # kubernetes +rich==13.7.0 + # via + # cookiecutter + # flytekit + # rich-click +rich-click==1.7.3 + # via flytekit +rsa==4.9 + # via google-auth +s3fs==2023.9.2 + # via flytekit +six==1.16.0 + # via + # azure-core + # isodate + # kubernetes + # python-dateutil +statsd==3.3.0 + # via flytekit +text-unidecode==1.3 + # via python-slugify +types-python-dateutil==2.8.19.20240106 + # via arrow +typing-extensions==4.9.0 + # via + # aioitertools + # azure-core + # azure-storage-blob + # flytekit + # mashumaro + # rich + # rich-click + # typing-inspect +typing-inspect==0.9.0 + # via dataclasses-json +tzdata==2023.4 + # via pandas +urllib3==1.26.18 + # via + # botocore + # docker + # flytekit + # kubernetes + # requests +websocket-client==1.7.0 + # via + # docker + # kubernetes +wrapt==1.16.0 + # via aiobotocore +yarl==1.9.4 + # via aiohttp +zipp==3.17.0 + # via + # importlib-metadata + # importlib-resources diff --git a/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/__init__.py b/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/__init__.py new file mode 100644 index 0000000000..cfb9e96795 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/__init__.py @@ -0,0 +1,17 @@ +""" +.. currentmodule:: flytekitplugins.great_expectations + +This package contains things that are useful when extending Flytekit. + +.. autosummary:: + :template: custom.rst + :toctree: generated/ + + BatchRequestConfig + GreatExpectationsFlyteConfig + GreatExpectationsTask + GreatExpectationsType +""" + +from .schema import GreatExpectationsFlyteConfig, GreatExpectationsType # noqa: F401 +from .task import BatchRequestConfig, GreatExpectationsTask # noqa: F401 diff --git a/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/schema.py b/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/schema.py new file mode 100644 index 0000000000..3413cdcdd3 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/schema.py @@ -0,0 +1,327 @@ +import datetime +import os +import typing +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +from dataclasses_json import DataClassJsonMixin + +from flytekit import FlyteContext, lazy_module +from flytekit.extend import TypeEngine, TypeTransformer +from flytekit.loggers import logger +from flytekit.models import types as _type_models +from flytekit.models.literals import Literal, Primitive, Scalar +from flytekit.models.types import LiteralType +from flytekit.types.file.file import FlyteFile, FlyteFilePathTransformer +from flytekit.types.schema.types import FlyteSchema, FlyteSchemaTransformer + +from .task import BatchRequestConfig + +ge = lazy_module("great_expectations") + + +@dataclass +class GreatExpectationsFlyteConfig(DataClassJsonMixin): + """ + Use this configuration to configure GreatExpectations Plugin. + + Args: + datasource_name: tell where your data lives and how to get it + expectation_suite_name: suite which consists of the data expectations + data_connector_name: connector to identify data batches + data_asset_name: name of the data asset (to be used for RuntimeBatchRequest) + local_file_path: dataset file path useful for FlyteFile and FlyteSchema + checkpoint_params: optional SimpleCheckpoint parameters + batch_request_config: batchrequest config + context_root_dir: directory in which GreatExpectations' configuration resides + """ + + datasource_name: str + expectation_suite_name: str + data_connector_name: str + data_asset_name: Optional[str] = None + """ + local_file_path is a must in two scenrios: + * When using FlyteSchema + * When using FlyteFile for remote paths + This is because base directory which has the dataset file 'must' be given in GreatExpectations' config file + """ + local_file_path: Optional[str] = None + checkpoint_params: Optional[Dict[str, Union[str, List[str]]]] = None + batch_request_config: Optional[BatchRequestConfig] = None + context_root_dir: str = "./great_expectations" + + +class GreatExpectationsType(object): + """ + Use this class to send the GreatExpectationsFlyteConfig. + + Args: + config: GreatExpectations Plugin configuration + + TODO: Connect Data Docs to Flyte Console. + """ + + @classmethod + def config(cls) -> Tuple[Type, GreatExpectationsFlyteConfig]: + return ( + str, + GreatExpectationsFlyteConfig(datasource_name="", data_connector_name="", expectation_suite_name=""), + ) + + def __class_getitem__(cls, config: Tuple[Type, GreatExpectationsFlyteConfig]) -> Any: + if not (isinstance(config, tuple) or len(config) != 2): + raise AssertionError("GreatExpectationsType must have both datatype and GreatExpectationsFlyteConfig") + + class _GreatExpectationsTypeClass(GreatExpectationsType): + __origin__ = GreatExpectationsType + + @classmethod + def config(cls) -> Tuple[Type, GreatExpectationsFlyteConfig]: + return config + + return _GreatExpectationsTypeClass + + +class GreatExpectationsTypeTransformer(TypeTransformer[GreatExpectationsType]): + def __init__(self): + super().__init__(name="GreatExpectations Transformer", t=GreatExpectationsType) + + @staticmethod + def get_config(t: Type[GreatExpectationsType]) -> Tuple[Type, GreatExpectationsFlyteConfig]: + return t.config() + + def get_literal_type(self, t: Type[GreatExpectationsType]) -> LiteralType: + datatype = GreatExpectationsTypeTransformer.get_config(t)[0] + + if issubclass(datatype, str): + return LiteralType(simple=_type_models.SimpleType.STRING, metadata={}) + elif issubclass(datatype, FlyteFile): + return FlyteFilePathTransformer().get_literal_type(datatype) + elif issubclass(datatype, FlyteSchema): + return FlyteSchemaTransformer().get_literal_type(datatype) + else: + raise TypeError(f"{datatype} is not a supported type") + + def to_literal( + self, + ctx: FlyteContext, + python_val: Union[FlyteFile, FlyteSchema, str], + python_type: Type[GreatExpectationsType], + expected: LiteralType, + ) -> Literal: + datatype = GreatExpectationsTypeTransformer.get_config(python_type)[0] + + if issubclass(datatype, FlyteSchema): + return FlyteSchemaTransformer().to_literal(ctx, python_val, datatype, expected) + elif issubclass(datatype, FlyteFile): + return FlyteFilePathTransformer().to_literal(ctx, python_val, datatype, expected) + elif issubclass(datatype, str): + return Literal(scalar=Scalar(primitive=Primitive(string_value=python_val))) + else: + raise TypeError(f"{datatype} is not a supported type") + + def _flyte_schema( + self, + is_runtime: bool, + ctx: FlyteContext, + ge_conf: GreatExpectationsFlyteConfig, + lv: Literal, + expected_python_type: Type[FlyteSchema], + ) -> (FlyteSchema, str): + temp_dataset = "" + + # if data batch is to be generated, skip copying the parquet file + if not is_runtime: + if not ge_conf.local_file_path: + raise ValueError("local_file_path is missing!") + + # copy parquet file to user-given directory + if lv.scalar.structured_dataset: + ctx.file_access.get_data(lv.scalar.structured_dataset.uri, ge_conf.local_file_path, is_multipart=True) + else: + ctx.file_access.get_data(lv.scalar.schema.uri, ge_conf.local_file_path, is_multipart=True) + + temp_dataset = os.path.basename(ge_conf.local_file_path) + + return FlyteSchemaTransformer().to_python_value(ctx, lv, expected_python_type), temp_dataset + + def _flyte_file( + self, + ctx: FlyteContext, + ge_conf: GreatExpectationsFlyteConfig, + lv: Literal, + expected_python_type: Type[FlyteFile], + ) -> (FlyteFile, str): + if not ge_conf.local_file_path: + raise ValueError("local_file_path is missing!") + + uri = lv.scalar.blob.uri + + if os.path.isdir(ge_conf.local_file_path): + local_path = os.path.join(ge_conf.local_file_path, os.path.basename(uri)) + else: + local_path = ge_conf.local_file_path + + # download the file into local_file_path + ctx.file_access.get_data( + remote_path=uri, + local_path=local_path, + ) + + return FlyteFilePathTransformer().to_python_value(ctx, lv, expected_python_type), os.path.basename(uri) + + def to_python_value( + self, + ctx: FlyteContext, + lv: Literal, + expected_python_type: Type[GreatExpectationsType], + ) -> GreatExpectationsType: + if not ( + lv + and lv.scalar + and ( + (lv.scalar.primitive and lv.scalar.primitive.string_value) + or lv.scalar.schema + or lv.scalar.blob + or lv.scalar.structured_dataset + ) + ): + raise AssertionError("Can only validate a literal string/FlyteFile/FlyteSchema value") + + # fetch the configuration + type_conf = GreatExpectationsTypeTransformer.get_config(expected_python_type) + conf_dict = type_conf[1].to_dict() # type: ignore + + ge_conf = GreatExpectationsFlyteConfig(**conf_dict) + + # fetch the data context + context = ge.data_context.DataContext(ge_conf.context_root_dir) # type: ignore + + # determine the type of data connector + selected_datasource = list(filter(lambda x: x["name"] == ge_conf.datasource_name, context.list_datasources())) + + if not selected_datasource: + raise ValueError("Datasource doesn't exist!") + + data_connector_class_lookup = { + data_connector_name: data_connector_class["class_name"] + for data_connector_name, data_connector_class in selected_datasource[0]["data_connectors"].items() + } + + specified_data_connector_class = data_connector_class_lookup[ge_conf.data_connector_name] + + is_runtime = False + if specified_data_connector_class == "RuntimeDataConnector": + is_runtime = True + if not ge_conf.data_asset_name: + raise ValueError("data_asset_name has to be given in a RuntimeBatchRequest") + + # file path for FlyteSchema and FlyteFile + temp_dataset = "" + + # return value + return_dataset = "" + + # FlyteSchema + if lv.scalar.schema or lv.scalar.structured_dataset: + return_dataset, temp_dataset = self._flyte_schema( + is_runtime=is_runtime, ctx=ctx, ge_conf=ge_conf, lv=lv, expected_python_type=type_conf[0] + ) + + # FlyteFile + if lv.scalar.blob: + return_dataset, temp_dataset = self._flyte_file( + ctx=ctx, ge_conf=ge_conf, lv=lv, expected_python_type=type_conf[0] + ) + + if lv.scalar.primitive: + dataset = return_dataset = lv.scalar.primitive.string_value + else: + dataset = temp_dataset + + batch_request_conf = ge_conf.batch_request_config + + # minimalistic batch request + final_batch_request = { + "data_asset_name": ge_conf.data_asset_name if is_runtime else dataset, + "datasource_name": ge_conf.datasource_name, + "data_connector_name": ge_conf.data_connector_name, + } + + # Great Expectations' RuntimeBatchRequest + if batch_request_conf and (batch_request_conf["runtime_parameters"] or is_runtime): + final_batch_request.update( + { + "runtime_parameters": batch_request_conf["runtime_parameters"] + if batch_request_conf["runtime_parameters"] + else {}, + "batch_identifiers": batch_request_conf["batch_identifiers"], + "batch_spec_passthrough": batch_request_conf["batch_spec_passthrough"], + } + ) + + if is_runtime and lv.scalar.primitive: + final_batch_request["runtime_parameters"]["query"] = dataset + elif is_runtime and (lv.scalar.schema or lv.scalar.structured_dataset): + final_batch_request["runtime_parameters"]["batch_data"] = return_dataset.open().all() + else: + raise AssertionError("Can only use runtime_parameters for query(str)/schema data") + + # Great Expectations' BatchRequest + elif batch_request_conf: + final_batch_request.update( + { + "data_connector_query": batch_request_conf["data_connector_query"], + "batch_spec_passthrough": batch_request_conf["batch_spec_passthrough"], + } + ) + + if ge_conf.checkpoint_params: + checkpoint = ge.checkpoint.SimpleCheckpoint( + f"_tmp_checkpoint_{ge_conf.expectation_suite_name}", + context, + **ge_conf.checkpoint_params, + ) + else: + checkpoint = ge.checkpoint.SimpleCheckpoint(f"_tmp_checkpoint_{ge_conf.expectation_suite_name}", context) + + # identify every run uniquely + run_id = ge.core.run_identifier.RunIdentifier( + **{ + "run_name": ge_conf.datasource_name + "_run", + "run_time": datetime.datetime.utcnow(), + } + ) + + checkpoint_result = checkpoint.run( + run_id=run_id, + validations=[ + { + "batch_request": final_batch_request, + "expectation_suite_name": ge_conf.expectation_suite_name, + } + ], + ) + final_result = ge.core.util.convert_to_json_serializable(checkpoint_result.list_validation_results())[0] + + result_string = "" + if final_result["success"] is False: + for every_result in final_result["results"]: + if every_result["success"] is False: + result_string += ( + every_result["expectation_config"]["kwargs"]["column"] + + " -> " + + every_result["expectation_config"]["expectation_type"] + + "\n" + ) + + # raise a Great Expectations' exception + raise ge.exceptions.ValidationError("Validation failed!\nCOLUMN\t\tFAILED EXPECTATION\n" + result_string) + + logger.info("Validation succeeded!") + + return typing.cast(GreatExpectationsType, return_dataset) + + +TypeEngine.register(GreatExpectationsTypeTransformer()) diff --git a/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/task.py b/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/task.py new file mode 100644 index 0000000000..bd04f18782 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/flytekitplugins/great_expectations/task.py @@ -0,0 +1,250 @@ +import datetime +import os +import shutil +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Type, Union + +from dataclasses_json import DataClassJsonMixin + +from flytekit import PythonInstanceTask, lazy_module +from flytekit.core.context_manager import FlyteContext +from flytekit.extend import Interface +from flytekit.loggers import logger +from flytekit.types.file.file import FlyteFile +from flytekit.types.schema import FlyteSchema + +ge = lazy_module("great_expectations") + + +@dataclass +class BatchRequestConfig(DataClassJsonMixin): + """ + Use this configuration to configure Batch Request. A BatchRequest can either be + a simple BatchRequest or a RuntimeBatchRequest. + + Args: + data_connector_query: query to request data batch + runtime_parameters: parameters to be passed at runtime + batch_identifiers: identifiers to identify the data batch + batch_spec_passthrough: reader method if your file doesn't have an extension + """ + + data_connector_query: Optional[Dict[str, Any]] = None + runtime_parameters: Optional[Dict[str, Any]] = None + batch_identifiers: Optional[Dict[str, str]] = None + batch_spec_passthrough: Optional[Dict[str, Any]] = None + + +class GreatExpectationsTask(PythonInstanceTask[BatchRequestConfig]): + """ + This task can be used to validate your data. + You can use this when you want to validate your data within the task or workflow. + If you want to validate your data as and when the type is given, use the `GreatExpectationsType`. + + Args: + name: name of the task + datasource_name: tell where your data lives and how to get it + expectation_suite_name: suite which consists of the data expectations + data_connector_name: connector to identify data batches + inputs: inputs to pass to the execute() method + data_asset_name: name of the data asset (to be used for RuntimeBatchRequest) + local_file_path: dataset file path useful for FlyteFile and FlyteSchema + checkpoint_params: optional SimpleCheckpoint parameters + task_config: batchrequest config + context_root_dir: directory in which GreatExpectations' configuration resides + + TODO: Connect Data Docs to Flyte Console. + """ + + _TASK_TYPE = "great_expectations" + _RUNTIME_VAR_NAME = "runtime" + + def __init__( + self, + name: str, + datasource_name: str, + expectation_suite_name: str, + data_connector_name: str, + inputs: Dict[str, Type], + data_asset_name: Optional[str] = None, + outputs: Optional[Dict[str, Type]] = None, + local_file_path: Optional[str] = None, + checkpoint_params: Optional[Dict[str, Union[str, List[str]]]] = None, + task_config: BatchRequestConfig = None, + context_root_dir: str = "./great_expectations", + **kwargs, + ): + self._datasource_name = datasource_name + self._data_connector_name = data_connector_name + self._expectation_suite_name = expectation_suite_name + self._batch_request_config = task_config + self._context_root_dir = context_root_dir + self._data_asset_name = data_asset_name + """ + local_file_path is a must in two scenarios: + * When using FlyteSchema + * When using FlyteFile for remote paths + This is because base directory which has the dataset file 'must' be given in GreatExpectations' config file + """ + self._local_file_path = local_file_path + self._checkpoint_params = checkpoint_params + + outputs = {"result": Dict[Any, Any]} + + super(GreatExpectationsTask, self).__init__( + name=name, + task_config=task_config, + task_type=self._TASK_TYPE, + interface=Interface(inputs=inputs, outputs=outputs), + **kwargs, + ) + + def _flyte_file(self, dataset: FlyteFile) -> str: + if not self._local_file_path: + raise ValueError("local_file_path is missing!") + + shutil.copy(dataset, self._local_file_path) + return os.path.basename(dataset) + + def _flyte_schema(self, dataset: FlyteSchema) -> str: + if not self._local_file_path: + raise ValueError("local_file_path is missing!") + + # copy parquet file to user-given directory + FlyteContext.current_context().file_access.get_data( + dataset.remote_path, self._local_file_path, is_multipart=True + ) + return os.path.basename(self._local_file_path) + + def execute(self, **kwargs) -> Any: + context = ge.data_context.DataContext(self._context_root_dir) # type: ignore + + if len(self.python_interface.inputs.keys()) != 1: + raise TypeError("Expected one input argument to validate the dataset") + + dataset_key = list(self.python_interface.inputs.keys())[0] + dataset = kwargs[dataset_key] + datatype = self.python_interface.inputs[dataset_key] + + if not issubclass(datatype, (FlyteFile, FlyteSchema, str)): + raise TypeError("'dataset' has to have FlyteFile/FlyteSchema/str datatype") + + # determine the type of data connector + selected_datasource = list(filter(lambda x: x["name"] == self._datasource_name, context.list_datasources())) + + if not selected_datasource: + raise ValueError("Datasource doesn't exist!") + + data_connector_class_lookup = { + data_connector_name: data_connector_class["class_name"] + for data_connector_name, data_connector_class in selected_datasource[0]["data_connectors"].items() + } + + specified_data_connector_class = data_connector_class_lookup[self._data_connector_name] + + is_runtime = False + if specified_data_connector_class == "RuntimeDataConnector": + is_runtime = True + if not self._data_asset_name: + raise ValueError("data_asset_name has to be given in a RuntimeBatchRequest") + + # FlyteFile + if issubclass(datatype, FlyteFile): + dataset = self._flyte_file(dataset) + + # FlyteSchema + # convert schema to parquet file + if issubclass(datatype, FlyteSchema) and not is_runtime: + dataset = self._flyte_schema(dataset) + + # minimalistic batch request + final_batch_request = { + "data_asset_name": self._data_asset_name if is_runtime else dataset, + "datasource_name": self._datasource_name, + "data_connector_name": self._data_connector_name, + } + + # Great Expectations' RuntimeBatchRequest + if self._batch_request_config and (self._batch_request_config.runtime_parameters or is_runtime): + final_batch_request.update( + { + "runtime_parameters": self._batch_request_config.runtime_parameters + if self._batch_request_config.runtime_parameters + else {}, + "batch_identifiers": self._batch_request_config.batch_identifiers, + "batch_spec_passthrough": self._batch_request_config.batch_spec_passthrough, + } + ) + + if is_runtime and issubclass(datatype, str): + final_batch_request["runtime_parameters"]["query"] = dataset + elif is_runtime and issubclass(datatype, FlyteSchema): + # if execution engine is SparkDF, transform the data to pyspark.sql.dataframe.DataFrame, else transform the data + # to the default pandas.dataframe + if selected_datasource[0]["execution_engine"]["class_name"] == "SparkDFExecutionEngine": + import pyspark + + final_batch_request["runtime_parameters"]["batch_data"] = dataset.open( + pyspark.sql.dataframe.DataFrame + ).all() + else: + final_batch_request["runtime_parameters"]["batch_data"] = dataset.open().all() + else: + raise AssertionError("Can only use runtime_parameters for query(str)/schema data") + # Great Expectations' BatchRequest + elif self._batch_request_config: + final_batch_request.update( + { + "data_connector_query": self._batch_request_config.data_connector_query, + "batch_spec_passthrough": self._batch_request_config.batch_spec_passthrough, + } + ) + + if self._checkpoint_params: + checkpoint = ge.checkpoint.SimpleCheckpoint( + f"_tmp_checkpoint_{self._expectation_suite_name}", + context, + **self._checkpoint_params, + ) + else: + checkpoint = ge.checkpoint.SimpleCheckpoint( + f"_tmp_checkpoint_{self._expectation_suite_name}", + context, + ) + + # identify every run uniquely + run_id = ge.core.run_identifier.RunIdentifier( + **{ + "run_name": self._datasource_name + "_run", + "run_time": datetime.datetime.utcnow(), + } + ) + + checkpoint_result = checkpoint.run( + run_id=run_id, + validations=[ + { + "batch_request": final_batch_request, + "expectation_suite_name": self._expectation_suite_name, + } + ], + ) + final_result = ge.core.util.convert_to_json_serializable(checkpoint_result.list_validation_results())[0] + + result_string = "" + if final_result["success"] is False: + for every_result in final_result["results"]: + if every_result["success"] is False: + result_string += ( + every_result["expectation_config"]["kwargs"]["column"] + + " -> " + + every_result["expectation_config"]["expectation_type"] + + "\n" + ) + + # raise a Great Expectations' exception + raise ge.exceptions.ValidationError("Validation failed!\nCOLUMN\t\tFAILED EXPECTATION\n" + result_string) + + logger.info("Validation succeeded!") + + return final_result diff --git a/flytekit/plugins/flytekit-greatexpectations/setup.py b/flytekit/plugins/flytekit-greatexpectations/setup.py new file mode 100644 index 0000000000..f73b515539 --- /dev/null +++ b/flytekit/plugins/flytekit-greatexpectations/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup + +PLUGIN_NAME = "great_expectations" + +microlib_name = f"flytekitplugins-{PLUGIN_NAME}" + +plugin_requires = [ + "flytekit>=1.5.0,<2.0.0", + "great-expectations>=0.13.30", + "sqlalchemy>=1.4.23,<2.0.0", + "pyspark==3.3.1", + "s3fs<2023.6.0", +] + +__version__ = "0.0.0+develop" + +setup( + name=microlib_name, + version=__version__, + author="flyteorg", + author_email="admin@flyte.org", + description="Great Expectations Plugin for Flytekit", + 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", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/flytekit/plugins/flytekit-greatexpectations/tests/__init__.py b/flytekit/plugins/flytekit-greatexpectations/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flytekit/plugins/flytekit-greatexpectations/tests/data/movies.sqlite b/flytekit/plugins/flytekit-greatexpectations/tests/data/movies.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..2a6afb816dc46e51a8816db4e5e4537c01f77518 GIT binary patch literal 1953792 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

K* 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

G0F;m4(bJOTXr}v!r3`|(nROfSm)Kd-aWf2$ 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℘Uf7dRnFv}I{ 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

_hWB{4crWsv=-r@QK#XINccRwEFg49P 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⪙p2na@>l64v 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%0A9*-(eq>3cZ}^+= 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