diff --git a/LAST_VERSION b/LAST_VERSION index 807a3bb5..ea8cd288 100644 --- a/LAST_VERSION +++ b/LAST_VERSION @@ -1 +1 @@ -0.14.1.dev2 +0.15.0.dev2 diff --git a/README.md b/README.md index 1a440565..56665a14 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,38 @@
- Website -  •  - Documentation -  •  - Pypi -
-
+![](docs/assets/dotflow.png) -
+**Build production-ready data pipelines in minutes, not days.** -![](https://raw.githubusercontent.com/FernandoCelmer/dotflow/master/docs/assets/dotflow.gif) +[![PyPI](https://img.shields.io/pypi/v/dotflow?style=flat-square)](https://pypi.org/project/dotflow/) +[![Python](https://img.shields.io/pypi/pyversions/dotflow?style=flat-square)](https://pypi.org/project/dotflow/) +[![Downloads](https://img.shields.io/pypi/dm/dotflow?style=flat-square)](https://pypi.org/project/dotflow/) +[![Stars](https://img.shields.io/github/stars/dotflow-io?label=Stars&style=flat-square)](https://github.com/dotflow-io/dotflow) -![GitHub Org's stars](https://img.shields.io/github/stars/dotflow-io?label=Dotflow&style=flat-square) -![GitHub last commit](https://img.shields.io/github/last-commit/dotflow-io/dotflow?style=flat-square) -![PyPI](https://img.shields.io/pypi/v/dotflow?style=flat-square) -![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dotflow?style=flat-square) -![PyPI - Downloads](https://img.shields.io/pypi/dm/dotflow?style=flat-square) +[Website](https://dotflow.io) · [Documentation](https://dotflow-io.github.io/dotflow/) · [PyPI](https://pypi.org/project/dotflow/)
-# Welcome to Dotflow - -Dotflow is a lightweight Python library for building execution pipelines. Define tasks with decorators, chain them together, and run workflows in sequential, parallel, or background mode — with built-in retry, timeout, storage, notifications, and more. - -> **[Read the full documentation](https://dotflow-io.github.io/dotflow/)** - -## Table of Contents - -
-Click to expand - -- [Getting Help](#getting-help) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Features](#features) - - [Execution Modes](#execution-modes) - - [Retry, Timeout & Backoff](#retry-timeout--backoff) - - [Context System](#context-system) - - [Checkpoint & Resume](#checkpoint--resume) - - [Storage Providers](#storage-providers) - - [Notifications](#notifications) - - [Class-Based Steps](#class-based-steps) - - [Task Groups](#task-groups) - - [Callbacks](#callbacks) - - [Error Handling](#error-handling) - - [Async Support](#async-support) - - [Scheduler / Cron](#scheduler--cron) - - [CLI](#cli) - - [Dependency Injection via Config](#dependency-injection-via-config) -- [More Examples](#more-examples) -- [Commit Style](#commit-style) -- [License](#license) +--- -
+# Dotflow -## Getting Help +Dotflow is a lightweight Python library for execution pipelines. Define tasks with decorators, chain them together, and deploy to any cloud — with built-in retry, parallel execution, storage, observability, and cloud deployment. -We use GitHub issues for tracking bugs and feature requests. +## Why Dotflow? -- [Bug Report](https://github.com/dotflow-io/dotflow/issues/new/choose) -- [Documentation](https://github.com/dotflow-io/dotflow/issues/new/choose) -- [Feature Request](https://github.com/dotflow-io/dotflow/issues/new/choose) -- [Security Issue](https://github.com/dotflow-io/dotflow/issues/new/choose) -- [General Question](https://github.com/dotflow-io/dotflow/issues/new/choose) +- **Simple** — `@action` decorator + `workflow.start()`. That's it. +- **Resilient** — Retry, backoff, timeout, checkpoints, and error handling out of the box. +- **Observable** — OpenTelemetry traces, metrics, and logs. Sentry error tracking. +- **Deployable** — `dotflow deploy --platform lambda` ships your pipeline to AWS in one command. +- **Portable** — Same code runs on Lambda, ECS, Cloud Run, Kubernetes, Docker, or GitHub Actions. -## Installation +## Install ```bash pip install dotflow ``` -**Optional extras:** - -```bash -pip install dotflow[aws] # AWS S3 storage -pip install dotflow[gcp] # Google Cloud Storage -pip install dotflow[scheduler] # Cron-based scheduler -``` - ## Quick Start ```python @@ -105,9 +59,94 @@ workflow.task.add(step=load) workflow.start() ``` +## Deploy anywhere + +Write your pipeline once. Deploy to any cloud with a single command. + +```bash +dotflow init +dotflow deploy --platform lambda --project my_pipeline +``` + +### Supported platforms + +| Platform | Deploy method | +|----------|---------------| +| **Docker** | `docker compose up` | +| **AWS Lambda** | `dotflow deploy` | +| **AWS Lambda + EventBridge** | `dotflow deploy --schedule` | +| **AWS Lambda + S3 Trigger** | `dotflow deploy` | +| **AWS Lambda + SQS Trigger** | `dotflow deploy` | +| **AWS Lambda + API Gateway** | `dotflow deploy` | +| **AWS ECS Fargate** | `dotflow deploy` | +| **Google Cloud Run** | `gcloud run deploy` | +| **Kubernetes** | `kubectl apply` | +| **GitHub Actions** | `dotflow deploy` | + +> [See all 34+ platforms →](https://dotflow-io.github.io/dotflow/nav/cloud/) + +## Optional extras + +```bash +pip install dotflow[aws] # S3 storage +pip install dotflow[gcp] # Google Cloud Storage +pip install dotflow[scheduler] # Cron scheduler +pip install dotflow[otel] # OpenTelemetry +pip install dotflow[sentry] # Sentry error tracking +pip install dotflow[deploy-aws] # AWS deploy (Lambda, ECS) +pip install dotflow[deploy-gcp] # GCP deploy (Cloud Run) +pip install dotflow[deploy-github] # GitHub Actions deploy +``` + +> **[Read the full documentation →](https://dotflow-io.github.io/dotflow/)** + +## Documentation + +| Section | Description | +|---------|-------------| +| [Concepts](https://dotflow-io.github.io/dotflow/nav/concepts/concept-workflow-and-tasks/) | Workflows, tasks, context, providers, process modes | +| [How-to Guides](https://dotflow-io.github.io/dotflow/nav/how-to/) | Step-by-step tutorials for workflows, tasks, and CLI | +| [Cloud Deployment](https://dotflow-io.github.io/dotflow/nav/cloud/) | Deploy to AWS, GCP, Kubernetes, Docker, GitHub Actions | +| [Integrations](https://dotflow-io.github.io/dotflow/nav/integrations/) | OpenTelemetry, Sentry, Telegram, Discord, S3, GCS | +| [Examples](https://dotflow-io.github.io/dotflow/nav/examples/) | Real-world pipelines: ETL, health checks, async, scheduler | +| [Reference](https://dotflow-io.github.io/dotflow/nav/reference/dotflow/) | API reference for all classes and providers | +| [Custom Providers](https://dotflow-io.github.io/dotflow/nav/development/custom-providers/) | Build your own storage, notify, log, tracer, or metrics provider | + ## Features -### Execution Modes +
+Observability + +> [OpenTelemetry docs](https://dotflow-io.github.io/dotflow/nav/tutorial/log-opentelemetry/) | [Sentry docs](https://dotflow-io.github.io/dotflow/nav/tutorial/log-sentry/) | [Tracer docs](https://dotflow-io.github.io/dotflow/nav/tutorial/tracer-opentelemetry/) | [Metrics docs](https://dotflow-io.github.io/dotflow/nav/tutorial/metrics-opentelemetry/) + +Built-in support for OpenTelemetry and Sentry: + +```python +from dotflow import Config +from dotflow.providers import LogOpenTelemetry, TracerOpenTelemetry, MetricsOpenTelemetry + +# OpenTelemetry: traces, metrics, and structured logs +config = Config( + log=LogOpenTelemetry(service_name="my-pipeline"), + tracer=TracerOpenTelemetry(service_name="my-pipeline"), + metrics=MetricsOpenTelemetry(service_name="my-pipeline"), +) +``` + +```python +from dotflow.providers import LogSentry, TracerSentry + +# Sentry: error tracking + performance monitoring +config = Config( + log=LogSentry(dsn="https://xxx@sentry.io/123"), + tracer=TracerSentry(), +) +``` + +
+ +
+Execution Modes > [Process Mode docs](https://dotflow-io.github.io/dotflow/nav/concepts/process-mode-sequential/) @@ -178,7 +217,10 @@ flowchart TD --- -### Retry, Timeout & Backoff +
+ +
+Retry, Timeout & Backoff > [Retry docs](https://dotflow-io.github.io/dotflow/nav/tutorial/task-retry/) | [Backoff docs](https://dotflow-io.github.io/dotflow/nav/tutorial/task-backoff/) | [Timeout docs](https://dotflow-io.github.io/dotflow/nav/tutorial/task-timeout/) @@ -201,7 +243,10 @@ def unreliable_api_call(): --- -### Context System +
+ +
+Context System > [Context docs](https://dotflow-io.github.io/dotflow/nav/tutorial/initial-context/) | [Previous Context](https://dotflow-io.github.io/dotflow/nav/tutorial/previous-context/) | [Many Contexts](https://dotflow-io.github.io/dotflow/nav/tutorial/many-contexts/) @@ -232,7 +277,10 @@ Each `Context` object contains: --- -### Checkpoint & Resume +
+ +
+Checkpoint & Resume > [Checkpoint docs](https://dotflow-io.github.io/dotflow/nav/tutorial/checkpoint/) @@ -258,7 +306,10 @@ workflow.start(resume=True) --- -### Storage Providers +
+ +
+Storage Providers > [Storage docs](https://dotflow-io.github.io/dotflow/nav/tutorial/storage-default/) @@ -312,32 +363,39 @@ workflow = DotFlow(config=config) --- -### Notifications +
+ +
+Notifications -> [Telegram docs](https://dotflow-io.github.io/dotflow/nav/tutorial/notify-telegram/) +> [Telegram docs](https://dotflow-io.github.io/dotflow/nav/tutorial/notify-telegram/) | [Discord docs](https://dotflow-io.github.io/dotflow/nav/tutorial/notify-discord/) -Get notified about task status changes via Telegram. +Get notified about task status changes via Telegram or Discord. ```python -from dotflow import DotFlow, Config +from dotflow import Config from dotflow.providers import NotifyTelegram -from dotflow.core.types.status import TypeStatus -notify = NotifyTelegram( +config = Config(notify=NotifyTelegram( token="YOUR_BOT_TOKEN", chat_id=123456789, - notification_type=TypeStatus.FAILED, # only notify on failures (optional) -) - -config = Config(notify=notify) -workflow = DotFlow(config=config) +)) ``` -Status types: `NOT_STARTED`, `IN_PROGRESS`, `COMPLETED`, `PAUSED`, `RETRY`, `FAILED` +```python +from dotflow.providers import NotifyDiscord + +config = Config(notify=NotifyDiscord( + webhook_url="https://discord.com/api/webhooks/...", +)) +``` --- -### Class-Based Steps +
+ +
+Class-Based Steps Return a class instance from a task, and Dotflow will automatically discover and execute all `@action`-decorated methods in source order. @@ -369,7 +427,10 @@ workflow.start() --- -### Task Groups +
+ +
+Task Groups > [Groups docs](https://dotflow-io.github.io/dotflow/nav/tutorial/groups/) @@ -386,7 +447,10 @@ workflow.start() # groups run in parallel, tasks within each group run sequenti --- -### Callbacks +
+ +
+Callbacks > [Task Callback docs](https://dotflow-io.github.io/dotflow/nav/tutorial/task-callback/) | [Workflow Callback docs](https://dotflow-io.github.io/dotflow/nav/tutorial/workflow-callback/) @@ -413,7 +477,10 @@ workflow.start(on_success=on_success, on_failure=on_failure) --- -### Error Handling +
+ +
+Error Handling > [Error Handling docs](https://dotflow-io.github.io/dotflow/nav/tutorial/error-handling/) | [Keep Going docs](https://dotflow-io.github.io/dotflow/nav/tutorial/keep-going/) @@ -443,7 +510,10 @@ for task in workflow.result_task(): --- -### Async Support +
+ +
+Async Support > [Async docs](https://dotflow-io.github.io/dotflow/nav/tutorial/async-actions/) @@ -466,7 +536,10 @@ workflow.start() --- -### Scheduler / Cron +
+ +
+Scheduler / Cron > [Cron scheduler docs](https://dotflow-io.github.io/dotflow/nav/tutorial/scheduler-cron/) | [Default scheduler](https://dotflow-io.github.io/dotflow/nav/tutorial/scheduler-default/) | [Cron overlap (concepts)](https://dotflow-io.github.io/dotflow/nav/concepts/concept-cron-overlap/) @@ -515,7 +588,10 @@ The scheduler handles graceful shutdown via `SIGINT`/`SIGTERM` signals automatic --- -### CLI +
+ +
+CLI > [CLI docs](https://dotflow-io.github.io/dotflow/nav/how-to/cli/simple-start/) @@ -557,14 +633,20 @@ Available CLI commands: | Command | Description | |---------|-------------| -| `dotflow init` | Initialize a new Dotflow project | +| `dotflow init` | Scaffold a new project with cloud support | | `dotflow start` | Run a workflow | | `dotflow schedule` | Run a workflow on a cron schedule | -| `dotflow log` | View execution logs | +| `dotflow logs` | View execution logs | +| `dotflow cloud list` | Show available cloud platforms | +| `dotflow cloud generate --platform ` | Generate deployment files | +| `dotflow deploy --platform --project ` | Deploy to cloud | --- -### Dependency Injection via Config +
+ +
+Dependency Injection via Config The `Config` class lets you swap providers for storage, notifications, logging, and scheduling: @@ -593,7 +675,10 @@ Extend Dotflow by implementing the abstract base classes: --- -### Results & Inspection +
+ +
+Results & Inspection After execution, inspect results directly from the workflow object: @@ -624,7 +709,10 @@ workflow.task.schema() # Pydantic schema of the workflow --- -### Dynamic Module Import +
+ +
+Dynamic Module Import Reference tasks and callbacks by their module path string instead of importing them directly: @@ -635,124 +723,12 @@ workflow.task.add(step="my_package.tasks.save_results", callback="my_package.cal --- -## More Examples - -All examples are available in the [`docs_src/`](https://github.com/dotflow-io/dotflow/tree/develop/docs_src) directory. - -#### Basic - -| Example | Description | Command | -|---------|-------------|---------| -| [first_steps](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/first_steps/first_steps.py) | Minimal workflow with callback | `python docs_src/first_steps/first_steps.py` | -| [simple_function_workflow](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/basic/simple_function_workflow.py) | Simple function-based workflow | `python docs_src/basic/simple_function_workflow.py` | -| [simple_class_workflow](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/basic/simple_class_workflow.py) | Class-based step with retry | `python docs_src/basic/simple_class_workflow.py` | -| [simple_function_workflow_with_error](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/basic/simple_function_workflow_with_error.py) | Error inspection after failure | `python docs_src/basic/simple_function_workflow_with_error.py` | - -#### Async - -| Example | Description | Command | -|---------|-------------|---------| -| [async_action](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/async/async_action.py) | Async task functions | `python docs_src/async/async_action.py` | - -#### Context - -| Example | Description | Command | -|---------|-------------|---------| -| [context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/context/context.py) | Creating and inspecting a Context | `python docs_src/context/context.py` | -| [initial_context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/initial_context/initial_context.py) | Passing initial context per task | `python docs_src/initial_context/initial_context.py` | -| [previous_context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/previous_context/previous_context.py) | Chaining context between tasks | `python docs_src/previous_context/previous_context.py` | -| [many_contexts](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/context/many_contexts.py) | Using both initial and previous context | `python docs_src/context/many_contexts.py` | - -#### Process Modes - -| Example | Description | Command | -|---------|-------------|---------| -| [sequential](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/process_mode/sequential.py) | Sequential execution | `python docs_src/process_mode/sequential.py` | -| [background](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/process_mode/background.py) | Background (non-blocking) execution | `python docs_src/process_mode/background.py` | -| [parallel](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/process_mode/parallel.py) | Parallel execution | `python docs_src/process_mode/parallel.py` | -| [parallel_group](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/process_mode/parallel_group.py) | Parallel groups execution | `python docs_src/process_mode/parallel_group.py` | -| [sequential_group_mode](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/workflow/sequential_group_mode.py) | Sequential with named groups | `python docs_src/workflow/sequential_group_mode.py` | - -#### Resilience (Retry, Backoff, Timeout) - -| Example | Description | Command | -|---------|-------------|---------| -| [retry](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/retry/retry.py) | Retry on function and class steps | `python docs_src/retry/retry.py` | -| [retry_delay](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/retry/retry_delay.py) | Retry with delay between attempts | `python docs_src/retry/retry_delay.py` | -| [backoff](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/backoff/backoff.py) | Exponential backoff on retries | `python docs_src/backoff/backoff.py` | -| [timeout](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/timeout/timeout.py) | Timeout per task execution | `python docs_src/timeout/timeout.py` | - -#### Callbacks - -| Example | Description | Command | -|---------|-------------|---------| -| [task_callback](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/callback/task_callback.py) | Per-task callback on completion | `python docs_src/callback/task_callback.py` | -| [workflow_callback_success](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/callback/workflow_callback_success.py) | Workflow-level success callback | `python docs_src/callback/workflow_callback_success.py` | -| [workflow_callback_failure](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/callback/workflow_callback_failure.py) | Workflow-level failure callback | `python docs_src/callback/workflow_callback_failure.py` | - -#### Error Handling - -| Example | Description | Command | -|---------|-------------|---------| -| [errors](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/errors/errors.py) | Inspecting task errors and retry count | `python docs_src/errors/errors.py` | -| [keep_going_true](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/workflow/keep_going_true.py) | Continue workflow after task failure | `python docs_src/workflow/keep_going_true.py` | - -#### Groups - -| Example | Description | Command | -|---------|-------------|---------| -| [step_with_groups](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/group/step_with_groups.py) | Tasks in named parallel groups | `python docs_src/group/step_with_groups.py` | - -#### Storage - -| Example | Description | Command | -|---------|-------------|---------| -| [storage_file](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/storage/storage_file.py) | File-based JSON storage | `python docs_src/storage/storage_file.py` | -| [storage_s3](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/storage/storage_s3.py) | AWS S3 storage | `python docs_src/storage/storage_s3.py` | -| [storage_gcs](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/storage/storage_gcs.py) | Google Cloud Storage | `python docs_src/storage/storage_gcs.py` | - -#### Checkpoint & Resume - -| Example | Description | Command | -|---------|-------------|---------| -| [checkpoint](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/checkpoint/checkpoint.py) | Resume workflow from last checkpoint | `python docs_src/checkpoint/checkpoint.py` | - -#### Notifications - -| Example | Description | Command | -|---------|-------------|---------| -| [notify_telegram](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/notify/notify_telegram.py) | Telegram notifications on failure | `python docs_src/notify/notify_telegram.py` | - -#### Config & Providers +
-| Example | Description | Command | -|---------|-------------|---------| -| [config](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/config/config.py) | Full Config with storage, notify, log | `python docs_src/config/config.py` | -| [storage_provider](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/config/storage_provider.py) | Swapping storage providers | `python docs_src/config/storage_provider.py` | -| [notify_provider](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/config/notify_provider.py) | Swapping notification providers | `python docs_src/config/notify_provider.py` | -| [log_provider](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/config/log_provider.py) | Custom log provider | `python docs_src/config/log_provider.py` | -#### Results & Output +## More Examples -| Example | Description | Command | -|---------|-------------|---------| -| [step_function_result_task](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/output/step_function_result_task.py) | Inspect task results (function) | `python docs_src/output/step_function_result_task.py` | -| [step_function_result_context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/output/step_function_result_context.py) | Inspect context results (function) | `python docs_src/output/step_function_result_context.py` | -| [step_function_result_storage](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/output/step_function_result_storage.py) | Inspect storage results (function) | `python docs_src/output/step_function_result_storage.py` | -| [step_class_result_task](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/output/step_class_result_task.py) | Inspect task results (class) | `python docs_src/output/step_class_result_task.py` | -| [step_class_result_context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/output/step_class_result_context.py) | Inspect context results (class) | `python docs_src/output/step_class_result_context.py` | -| [step_class_result_storage](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/output/step_class_result_storage.py) | Inspect storage results (class) | `python docs_src/output/step_class_result_storage.py` | - -#### CLI - -| Example | Description | Command | -|---------|-------------|---------| -| [simple_cli](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/basic/simple_cli.py) | Basic CLI execution | `dotflow start --step docs_src.basic.simple_cli.simple_step` | -| [cli_with_callback](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/cli/cli_with_callback.py) | CLI with callback function | `dotflow start --step docs_src.cli.cli_with_callback.simple_step --callback docs_src.cli.cli_with_callback.callback` | -| [cli_with_initial_context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/cli/cli_with_initial_context.py) | CLI with initial context | `dotflow start --step docs_src.cli.cli_with_initial_context.simple_step --initial-context abc` | -| [cli_with_mode](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/cli/cli_with_mode.py) | CLI with execution mode | `dotflow start --step docs_src.cli.cli_with_mode.simple_step --mode sequential` | -| [cli_with_output_context](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/cli/cli_with_output_context.py) | CLI with file storage output | `dotflow start --step docs_src.cli.cli_with_output_context.simple_step --storage file` | -| [cli_with_path](https://github.com/dotflow-io/dotflow/blob/develop/docs_src/cli/cli_with_path.py) | CLI with custom storage path | `dotflow start --step docs_src.cli.cli_with_path.simple_step --path .storage --storage file` | +All examples are available in the [`docs_src/`](https://github.com/dotflow-io/dotflow/tree/develop/docs_src) directory. ## Commit Style diff --git a/docs/assets/dotflow.png b/docs/assets/dotflow.png new file mode 100644 index 00000000..f5e7d0d3 Binary files /dev/null and b/docs/assets/dotflow.png differ diff --git a/docs/index.md b/docs/index.md index cce2c935..0ea00527 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ Pypi -![](https://raw.githubusercontent.com/FernandoCelmer/dotflow/master/docs/assets/dotflow.gif) +![](assets/dotflow.png) ![GitHub Org's stars](https://img.shields.io/github/stars/dotflow-io?label=Dotflow&style=flat-square) ![GitHub last commit](https://img.shields.io/github/last-commit/dotflow-io/dotflow?style=flat-square) diff --git a/docs/nav/cloud/app-runner.md b/docs/nav/cloud/app-runner.md new file mode 100644 index 00000000..69b28fc4 --- /dev/null +++ b/docs/nav/cloud/app-runner.md @@ -0,0 +1,5 @@ +# App Runner + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/aws-batch.md b/docs/nav/cloud/aws-batch.md new file mode 100644 index 00000000..ae1e9a8f --- /dev/null +++ b/docs/nav/cloud/aws-batch.md @@ -0,0 +1,5 @@ +# Aws Batch + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/azure-container-instances.md b/docs/nav/cloud/azure-container-instances.md new file mode 100644 index 00000000..1741f728 --- /dev/null +++ b/docs/nav/cloud/azure-container-instances.md @@ -0,0 +1,5 @@ +# Azure Container Instances + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/azure-container.md b/docs/nav/cloud/azure-container.md new file mode 100644 index 00000000..7f44cca0 --- /dev/null +++ b/docs/nav/cloud/azure-container.md @@ -0,0 +1,5 @@ +# Azure Container + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/azure-functions.md b/docs/nav/cloud/azure-functions.md new file mode 100644 index 00000000..cca3fcda --- /dev/null +++ b/docs/nav/cloud/azure-functions.md @@ -0,0 +1,5 @@ +# Azure Functions + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/azure-logic-apps.md b/docs/nav/cloud/azure-logic-apps.md new file mode 100644 index 00000000..37eb0c1c --- /dev/null +++ b/docs/nav/cloud/azure-logic-apps.md @@ -0,0 +1,5 @@ +# Azure Logic Apps + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/azure-queue-trigger.md b/docs/nav/cloud/azure-queue-trigger.md new file mode 100644 index 00000000..68fadfde --- /dev/null +++ b/docs/nav/cloud/azure-queue-trigger.md @@ -0,0 +1,5 @@ +# Azure Queue Trigger + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/cloud-functions.md b/docs/nav/cloud/cloud-functions.md new file mode 100644 index 00000000..16811baf --- /dev/null +++ b/docs/nav/cloud/cloud-functions.md @@ -0,0 +1,5 @@ +# Cloud Functions + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/cloud-run-scheduled.md b/docs/nav/cloud/cloud-run-scheduled.md new file mode 100644 index 00000000..a25139c5 --- /dev/null +++ b/docs/nav/cloud/cloud-run-scheduled.md @@ -0,0 +1,51 @@ +# Google Cloud Run + Cloud Scheduler + +Run your dotflow pipeline on a recurring schedule using Cloud Run and Cloud Scheduler. + +## Create project + +```bash +dotflow init +# Select cloud: cloud-run-scheduled +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform cloud-run-scheduled +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Container image | +| `cloudbuild.yaml` | Cloud Build configuration | +| `scheduler.yaml` | Cloud Scheduler job definition | + +## Prerequisites + +- Google Cloud CLI (`gcloud`) installed and authenticated + +## Deploy + +```bash +gcloud auth login +gcloud config set project + +gcloud services enable cloudbuild.googleapis.com run.googleapis.com cloudscheduler.googleapis.com + +gcloud run deploy my_pipeline --source . --region us-central1 --no-allow-unauthenticated + +gcloud scheduler jobs create http my_pipeline-trigger \ + --schedule="0 */6 * * *" \ + --uri="" \ + --http-method=POST \ + --oidc-service-account-email=-compute@developer.gserviceaccount.com \ + --location=us-central1 +``` + +## Important + +- Replace `` with the actual URL from the `gcloud run deploy` output +- The scheduler uses OIDC authentication to invoke the Cloud Run service diff --git a/docs/nav/cloud/cloud-run.md b/docs/nav/cloud/cloud-run.md new file mode 100644 index 00000000..53e2b0ba --- /dev/null +++ b/docs/nav/cloud/cloud-run.md @@ -0,0 +1,51 @@ +# Google Cloud Run + +Deploy your dotflow pipeline to Google Cloud Run. + +## Create project + +```bash +dotflow init +# Select cloud: cloud-run +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform cloud-run +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Container image | +| `cloudbuild.yaml` | Cloud Build configuration | + +## Prerequisites + +- Google Cloud CLI (`gcloud`) installed and authenticated + +## Deploy + +```bash +gcloud auth login +gcloud config set project + +# Enable APIs +gcloud services enable cloudbuild.googleapis.com run.googleapis.com artifactregistry.googleapis.com + +# Deploy (builds and deploys in one step) +gcloud run deploy my_pipeline --source . --region us-central1 --no-allow-unauthenticated +``` + +## View logs + +```bash +gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=my_pipeline" --limit 50 --format="value(textPayload)" +``` + +## Important + +- Do not rename `workflow.py` or the `main()` function — the Dockerfile CMD depends on it +- Enable all required APIs before deploying diff --git a/docs/nav/cloud/cloud-tasks.md b/docs/nav/cloud/cloud-tasks.md new file mode 100644 index 00000000..87f4cbc0 --- /dev/null +++ b/docs/nav/cloud/cloud-tasks.md @@ -0,0 +1,5 @@ +# Cloud Tasks + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/cloud-workflows.md b/docs/nav/cloud/cloud-workflows.md new file mode 100644 index 00000000..54bb96e3 --- /dev/null +++ b/docs/nav/cloud/cloud-workflows.md @@ -0,0 +1,5 @@ +# Cloud Workflows + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/coolify.md b/docs/nav/cloud/coolify.md new file mode 100644 index 00000000..15060200 --- /dev/null +++ b/docs/nav/cloud/coolify.md @@ -0,0 +1,5 @@ +# Coolify + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/digital-ocean.md b/docs/nav/cloud/digital-ocean.md new file mode 100644 index 00000000..415928b1 --- /dev/null +++ b/docs/nav/cloud/digital-ocean.md @@ -0,0 +1,5 @@ +# Digital Ocean + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/docker.md b/docs/nav/cloud/docker.md new file mode 100644 index 00000000..e54d73dd --- /dev/null +++ b/docs/nav/cloud/docker.md @@ -0,0 +1,34 @@ +# Docker + +Deploy your dotflow pipeline with Docker and Docker Compose. + +## Create project + +```bash +dotflow init +# Select cloud: docker +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform docker +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Container image with your pipeline | +| `docker-compose.yml` | Service definition for local or production use | + +## Deploy + +```bash +docker compose build +docker compose up +``` + +## Important + +- Do not rename `workflow.py` or the `main()` function — the Dockerfile CMD depends on it diff --git a/docs/nav/cloud/ecs-scheduled.md b/docs/nav/cloud/ecs-scheduled.md new file mode 100644 index 00000000..2d3b76a5 --- /dev/null +++ b/docs/nav/cloud/ecs-scheduled.md @@ -0,0 +1,54 @@ +# AWS ECS + EventBridge Schedule + +Run your dotflow pipeline on a recurring schedule using ECS Fargate and EventBridge. + +## Create project + +```bash +dotflow init +# Select cloud: ecs-scheduled +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform ecs-scheduled +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Container image | +| `template.yaml` | CloudFormation template with ECS cluster, task definition, and EventBridge rule | + +## Prerequisites + +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +```bash +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +REGION=us-east-1 + +aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com +aws ecr create-repository --repository-name my_pipeline --region $REGION +docker build -t my_pipeline . +docker tag my_pipeline:latest $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/my_pipeline:latest +docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/my_pipeline:latest + +aws cloudformation deploy \ + --template-file template.yaml \ + --stack-name my_pipeline \ + --capabilities CAPABILITY_IAM \ + --parameter-overrides SubnetId= VpcId= \ + --region $REGION +``` + +## Important + +- The schedule expression is configured during project creation +- The stack creates its own ECS cluster, IAM roles, log group, and security group +- You need to provide `SubnetId` and `VpcId` as parameters diff --git a/docs/nav/cloud/ecs.md b/docs/nav/cloud/ecs.md new file mode 100644 index 00000000..9eff48cf --- /dev/null +++ b/docs/nav/cloud/ecs.md @@ -0,0 +1,72 @@ +# AWS ECS (Fargate) + +Deploy your dotflow pipeline as a Fargate task on Amazon ECS. + +## Create project + +```bash +dotflow init +# Select cloud: ecs +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform ecs +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Container image | +| `task-definition.json` | ECS task definition for Fargate | + +## Prerequisites + +- `pip install dotflow[aws]` +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform ecs --project my_pipeline --region us-east-1 +``` + +This creates: ECR repository, IAM role, CloudWatch log group, ECS cluster, task definition, and pushes the image. + +### Option 2: Manual + +```bash +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +REGION=us-east-1 + +aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com +aws ecr create-repository --repository-name my_pipeline --region $REGION +docker build -t my_pipeline . +docker tag my_pipeline:latest $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/my_pipeline:latest +docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/my_pipeline:latest + +aws logs create-log-group --log-group-name /ecs/my_pipeline --region $REGION +aws ecs register-task-definition --cli-input-json file://task-definition.json --region $REGION +aws ecs create-cluster --cluster-name my_pipeline-cluster --region $REGION + +SUBNET=$(aws ec2 describe-subnets --query "Subnets[0].SubnetId" --output text --region $REGION) +aws ecs run-task --cluster my_pipeline-cluster --task-definition my_pipeline --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[$SUBNET],assignPublicIp=ENABLED}" --region $REGION +``` + +## View logs + +```bash +aws logs tail /ecs/my_pipeline --region us-east-1 --since 5m +``` + +## Important + +- Do not rename `workflow.py` or the `main()` function — the Dockerfile CMD depends on it +- Create the CloudWatch log group before running the task +- The `aws_account_id` and `aws_region` must match your actual AWS account diff --git a/docs/nav/cloud/fly-io.md b/docs/nav/cloud/fly-io.md new file mode 100644 index 00000000..d5bb3325 --- /dev/null +++ b/docs/nav/cloud/fly-io.md @@ -0,0 +1,5 @@ +# Fly Io + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/github-actions.md b/docs/nav/cloud/github-actions.md new file mode 100644 index 00000000..beb76b37 --- /dev/null +++ b/docs/nav/cloud/github-actions.md @@ -0,0 +1,55 @@ +# GitHub Actions + +Run your dotflow pipeline on a schedule or manually using GitHub Actions as the runtime. + +## Create project + +```bash +dotflow init +# Select cloud: github-actions +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform github-actions +``` + +## Generated files + +| File | Description | +|------|-------------| +| `.github/workflows/dotflow.yml` | Workflow with cron schedule and manual trigger | + +## Prerequisites + +- `pip install dotflow[deploy-github]` +- GitHub token: `export GITHUB_TOKEN=$(gh auth token)` + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform github-actions --project my_pipeline +``` + +Creates a private repo on GitHub, pushes all project files, and GitHub Actions runs automatically. + +### Option 2: Manual + +```bash +git init && git add . && git commit -m "initial" +gh repo create my_pipeline --push --source . +``` + +## View logs + +Go to your repository > **Actions** tab > select the workflow run. + +## Important + +- Cron uses **UTC timezone** +- Minimum interval is 5 minutes +- `workflow_dispatch` allows manual trigger from the GitHub UI +- No secrets or credentials needed unless your pipeline uses external services diff --git a/docs/nav/cloud/gitlab-ci.md b/docs/nav/cloud/gitlab-ci.md new file mode 100644 index 00000000..5ce00f7b --- /dev/null +++ b/docs/nav/cloud/gitlab-ci.md @@ -0,0 +1,5 @@ +# Gitlab Ci + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/heroku.md b/docs/nav/cloud/heroku.md new file mode 100644 index 00000000..ef21edc0 --- /dev/null +++ b/docs/nav/cloud/heroku.md @@ -0,0 +1,5 @@ +# Heroku + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/index.md b/docs/nav/cloud/index.md new file mode 100644 index 00000000..30c5638a --- /dev/null +++ b/docs/nav/cloud/index.md @@ -0,0 +1,317 @@ +# Cloud Deployment + +Deploy your dotflow pipelines to any major cloud provider. Choose a target platform during project creation and get all the infrastructure files you need — fully configured and ready to deploy. + +## Docker + +
+ +- :fontawesome-brands-docker: **Docker** + + --- + + `Dockerfile` + `docker-compose.yml` for local or any container runtime. + + [:octicons-arrow-right-24: Guide](docker.md) + +
+ +## AWS + +
+ +- :fontawesome-brands-aws: **Lambda** + + --- + + Container-based Lambda function. Deploy with `dotflow deploy` or `sam deploy`. + + [:octicons-arrow-right-24: Guide](lambda.md) + +- :fontawesome-brands-aws: **Lambda + EventBridge** + + --- + + Scheduled Lambda with cron/rate expressions. Deploy with `dotflow deploy --schedule`. + + [:octicons-arrow-right-24: Guide](lambda-scheduled.md) + +- :fontawesome-brands-aws: **Lambda + S3 Trigger** + + --- + + Pipeline triggered by S3 file upload. Deploy with `dotflow deploy`. + + [:octicons-arrow-right-24: Guide](lambda-s3-trigger.md) + +- :fontawesome-brands-aws: **Lambda + SQS Trigger** + + --- + + Pipeline triggered by SQS messages. Deploy with `dotflow deploy`. + + [:octicons-arrow-right-24: Guide](lambda-sqs-trigger.md) + +- :fontawesome-brands-aws: **Lambda + API Gateway** + + --- + + Pipeline triggered by HTTP POST. Deploy with `dotflow deploy`. + + [:octicons-arrow-right-24: Guide](lambda-api-trigger.md) + +- :fontawesome-brands-aws: **ECS Fargate** + + --- + + Task definition for ECS Fargate. Deploy with `dotflow deploy`. + + [:octicons-arrow-right-24: Guide](ecs.md) + +- :fontawesome-brands-aws: **ECS + EventBridge** + + --- + + Scheduled Fargate task. Deploy with CloudFormation. + + [:octicons-arrow-right-24: Guide](ecs-scheduled.md) + +- :fontawesome-brands-aws: **Step Functions** :soon: + + --- + + State machine with Lambda tasks via SAM. + + [:octicons-arrow-right-24: Guide](step-functions.md) + +- :fontawesome-brands-aws: **Batch** :soon: + + --- + + Fargate batch job for heavy workloads. + + [:octicons-arrow-right-24: Guide](aws-batch.md) + +- :fontawesome-brands-aws: **App Runner** :soon: + + --- + + Serverless container service with minimal config. + + [:octicons-arrow-right-24: Guide](app-runner.md) + +
+ +## GCP + +
+ +- :fontawesome-brands-google: **Cloud Run** + + --- + + Deploy with `gcloud run deploy`. + + [:octicons-arrow-right-24: Guide](cloud-run.md) + +- :fontawesome-brands-google: **Cloud Run + Scheduler** + + --- + + Scheduled Cloud Run with Cloud Scheduler. + + [:octicons-arrow-right-24: Guide](cloud-run-scheduled.md) + +- :fontawesome-brands-google: **Cloud Functions** :soon: + + --- + + Serverless function with HTTP trigger. + + [:octicons-arrow-right-24: Guide](cloud-functions.md) + +- :fontawesome-brands-google: **Cloud Workflows** :soon: + + --- + + Orchestration workflow definition. + + [:octicons-arrow-right-24: Guide](cloud-workflows.md) + +- :fontawesome-brands-google: **Cloud Tasks** :soon: + + --- + + HTTP task queue with Cloud Run backend. + + [:octicons-arrow-right-24: Guide](cloud-tasks.md) + +- :fontawesome-brands-google: **Pub/Sub Trigger** :soon: + + --- + + Cloud Run triggered by Pub/Sub messages. + + [:octicons-arrow-right-24: Guide](pubsub-trigger.md) + +
+ +## Kubernetes + +
+ +- :material-kubernetes: **Kubernetes** + + --- + + Deploy with `kubectl apply`. + + [:octicons-arrow-right-24: Guide](kubernetes.md) + +
+ +## Azure :soon: + +
+ +- :material-microsoft-azure: **Azure Functions** + + --- + + Timer-triggered function with `function.json`. + + [:octicons-arrow-right-24: Guide](azure-functions.md) + +- :material-microsoft-azure: **Azure Container Apps** + + --- + + Containerized pipeline on Azure Container Apps. + + [:octicons-arrow-right-24: Guide](azure-container.md) + +- :material-microsoft-azure: **Azure Container Instances** :soon: + + --- + + One-shot container execution. + + [:octicons-arrow-right-24: Guide](azure-container-instances.md) + +- :material-microsoft-azure: **Azure Logic Apps** :soon: + + --- + + Orchestration workflow with HTTP trigger. + + [:octicons-arrow-right-24: Guide](azure-logic-apps.md) + +- :material-microsoft-azure: **Azure Queue Trigger** :soon: + + --- + + Azure Function triggered by Storage Queue messages. + + [:octicons-arrow-right-24: Guide](azure-queue-trigger.md) + +
+ +## CI/CD + +
+ +- :fontawesome-brands-github: **GitHub Actions** + + --- + + Scheduled workflow running on GitHub Actions. Deploy with `dotflow deploy`. + + [:octicons-arrow-right-24: Guide](github-actions.md) + +- :fontawesome-brands-gitlab: **GitLab CI** :soon: + + --- + + CI/CD pipeline with schedule and manual trigger. + + [:octicons-arrow-right-24: Guide](gitlab-ci.md) + +
+ +## More platforms :soon: + +
+ +- :material-cloud-outline: **Heroku** + + --- + + `Procfile` + `heroku.yml` for Heroku deployment. + + [:octicons-arrow-right-24: Guide](heroku.md) + +- :material-cloud-outline: **Fly.io** + + --- + + `fly.toml` for Fly.io edge deployment. + + [:octicons-arrow-right-24: Guide](fly-io.md) + +- :material-cloud-outline: **Railway** + + --- + + `railway.json` for Railway deployment. + + [:octicons-arrow-right-24: Guide](railway.md) + +- :material-cloud-outline: **Render** + + --- + + `render.yaml` for Render deployment. + + [:octicons-arrow-right-24: Guide](render.md) + +- :material-cloud-outline: **DigitalOcean** + + --- + + `app.yaml` for DigitalOcean App Platform. + + [:octicons-arrow-right-24: Guide](digital-ocean.md) + +- :material-cloud-outline: **Vercel** :soon: + + --- + + Serverless function via HTTP endpoint. + + [:octicons-arrow-right-24: Guide](vercel.md) + +- :material-cloud-outline: **Coolify** :soon: + + --- + + Self-hosted PaaS deployment via Docker. + + [:octicons-arrow-right-24: Guide](coolify.md) + +- :material-cloud-outline: **Nomad** :soon: + + --- + + Batch job for HashiCorp Nomad orchestrator. + + [:octicons-arrow-right-24: Guide](nomad.md) + +- :material-cloud-outline: **Terraform** :soon: + + --- + + Infrastructure as Code. + + [:octicons-arrow-right-24: Guide](terraform.md) + +
diff --git a/docs/nav/cloud/kubernetes.md b/docs/nav/cloud/kubernetes.md new file mode 100644 index 00000000..92103cc3 --- /dev/null +++ b/docs/nav/cloud/kubernetes.md @@ -0,0 +1,64 @@ +# Kubernetes + +Deploy your dotflow pipeline to a Kubernetes cluster. + +## Create project + +```bash +dotflow init +# Select cloud: kubernetes +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform kubernetes +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Container image | +| `deployment.yaml` | Kubernetes Deployment manifest | +| `service.yaml` | Kubernetes Service manifest | + +## Prerequisites + +- Docker +- `kubectl` +- A Kubernetes cluster (minikube for local testing) + +## Deploy (local with minikube) + +```bash +brew install minikube +minikube start +eval $(minikube docker-env) + +docker build -t my_pipeline:latest . + +kubectl apply -f deployment.yaml +kubectl apply -f service.yaml +``` + +## Deploy (production) + +Replace `image` in `deployment.yaml` with your container registry URL and change `imagePullPolicy` to `Always`: + +```bash +kubectl apply -f deployment.yaml +kubectl apply -f service.yaml +``` + +## View logs + +```bash +kubectl logs -l app=my_pipeline +``` + +## Important + +- Do not rename `workflow.py` or the `main()` function — the Dockerfile CMD depends on it +- `imagePullPolicy: Never` is for local images only — change to `Always` for production +- Project names with `_` are automatically converted to `-` in Kubernetes resource names diff --git a/docs/nav/cloud/lambda-api-trigger.md b/docs/nav/cloud/lambda-api-trigger.md new file mode 100644 index 00000000..6727c748 --- /dev/null +++ b/docs/nav/cloud/lambda-api-trigger.md @@ -0,0 +1,66 @@ +# AWS Lambda + API Gateway + +Trigger your dotflow pipeline via HTTP POST through API Gateway. + +## Create project + +```bash +dotflow init +# Select cloud: lambda-api-trigger +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform lambda-api-trigger +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Lambda container image | +| `handler.py` | Lambda entry point with JSON body parsing | +| `template.yaml` | SAM template with HTTP API event | +| `samconfig.toml` | Pre-configured deployment settings | + +## Prerequisites + +- `pip install dotflow[deploy-aws]` +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform lambda-api-trigger --project my_pipeline +``` + +The endpoint URL is shown after deploy. + +### Option 2: SAM CLI + +```bash +aws ecr create-repository --repository-name my_pipeline --region us-east-1 +sam build +sam deploy +``` + +## Invoke + +```bash +curl -X POST https://.execute-api.us-east-1.amazonaws.com/workflow +``` + +## View logs + +```bash +aws logs tail /aws/lambda/my_pipeline --region us-east-1 --since 5m +``` + +## Important + +- The endpoint accepts POST requests at `/workflow` +- The request body is parsed as JSON and available in `handler.py` diff --git a/docs/nav/cloud/lambda-s3-trigger.md b/docs/nav/cloud/lambda-s3-trigger.md new file mode 100644 index 00000000..dbac6322 --- /dev/null +++ b/docs/nav/cloud/lambda-s3-trigger.md @@ -0,0 +1,59 @@ +# AWS Lambda + S3 Trigger + +Run your dotflow pipeline automatically when a file is uploaded to an S3 bucket. + +## Create project + +```bash +dotflow init +# Select cloud: lambda-s3-trigger +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform lambda-s3-trigger +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Lambda container image | +| `handler.py` | Lambda entry point | +| `template.yaml` | SAM template with S3 event trigger | +| `samconfig.toml` | Pre-configured deployment settings | + +## Prerequisites + +- `pip install dotflow[deploy-aws]` +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform lambda-s3-trigger --project my_pipeline +``` + +### Option 2: SAM CLI + +```bash +aws ecr create-repository --repository-name my_pipeline --region us-east-1 +sam build +sam deploy +``` + +## View logs + +```bash +sam logs --stack-name my_pipeline --tail +``` + +## Important + +- The SAM template creates an S3 bucket named `-source` +- The pipeline triggers on any file upload (`s3:ObjectCreated:*`) +- Edit `template.yaml` to filter by prefix or suffix diff --git a/docs/nav/cloud/lambda-scheduled.md b/docs/nav/cloud/lambda-scheduled.md new file mode 100644 index 00000000..ebdf92e2 --- /dev/null +++ b/docs/nav/cloud/lambda-scheduled.md @@ -0,0 +1,58 @@ +# AWS Lambda + EventBridge Schedule + +Run your dotflow pipeline on a recurring schedule using AWS Lambda and EventBridge. + +## Create project + +```bash +dotflow init +# Select cloud: lambda-scheduled +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform lambda-scheduled +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Lambda container image | +| `handler.py` | Lambda entry point | +| `template.yaml` | SAM template with EventBridge schedule trigger | +| `samconfig.toml` | Pre-configured deployment settings | + +## Prerequisites + +- `pip install dotflow[aws]` +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform lambda-scheduled --project my_pipeline --schedule "rate(6 hours)" +``` + +### Option 2: SAM CLI + +```bash +aws ecr create-repository --repository-name my_pipeline --region us-east-1 +sam build +sam deploy +``` + +## View logs + +```bash +sam logs --stack-name my_pipeline --tail +``` + +## Important + +- The schedule expression is configured during project creation (e.g. `rate(6 hours)`, `cron(0 12 * * ? *)`) +- Edit `template.yaml` to change the schedule after generation diff --git a/docs/nav/cloud/lambda-sqs-trigger.md b/docs/nav/cloud/lambda-sqs-trigger.md new file mode 100644 index 00000000..e1b3efa0 --- /dev/null +++ b/docs/nav/cloud/lambda-sqs-trigger.md @@ -0,0 +1,59 @@ +# AWS Lambda + SQS Trigger + +Run your dotflow pipeline when a message arrives in an SQS queue. + +## Create project + +```bash +dotflow init +# Select cloud: lambda-sqs-trigger +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform lambda-sqs-trigger +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Lambda container image | +| `handler.py` | Lambda entry point | +| `template.yaml` | SAM template with SQS event source | +| `samconfig.toml` | Pre-configured deployment settings | + +## Prerequisites + +- `pip install dotflow[deploy-aws]` +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform lambda-sqs-trigger --project my_pipeline +``` + +### Option 2: SAM CLI + +```bash +aws ecr create-repository --repository-name my_pipeline --region us-east-1 +sam build +sam deploy +``` + +## View logs + +```bash +sam logs --stack-name my_pipeline --tail +``` + +## Important + +- The SAM template creates an SQS queue named `-queue` +- Messages are processed one at a time (`BatchSize: 1`) +- `VisibilityTimeout` is set to 960s (Lambda timeout + buffer) diff --git a/docs/nav/cloud/lambda.md b/docs/nav/cloud/lambda.md new file mode 100644 index 00000000..d40ff64f --- /dev/null +++ b/docs/nav/cloud/lambda.md @@ -0,0 +1,79 @@ +# AWS Lambda + +Deploy your dotflow pipeline as a container-based AWS Lambda function. + +## Create project + +```bash +dotflow init +# Select cloud: lambda +``` + +Or generate files for an existing project: + +```bash +dotflow cloud generate --platform lambda +``` + +## Generated files + +| File | Description | +|------|-------------| +| `Dockerfile` | Lambda container image | +| `handler.py` | Lambda entry point that calls your workflow | +| `template.yaml` | SAM template defining the function | +| `samconfig.toml` | Pre-configured deployment settings | + +## Prerequisites + +- `pip install dotflow[aws]` +- AWS CLI configured (`aws configure`) +- Docker + +## Deploy + +### Option 1: dotflow deploy + +```bash +dotflow deploy --platform lambda --project my_pipeline +``` + +### Option 2: SAM CLI + +```bash +aws ecr create-repository --repository-name my_pipeline --region us-east-1 +sam build +sam deploy +``` + +## Invoke + +```bash +# Via dotflow deploy +aws lambda invoke --function-name my_pipeline --region us-east-1 /dev/stdout + +# Via SAM +sam remote invoke +``` + +## View logs + +```bash +sam logs --stack-name my_pipeline --tail +``` + +## Variants + +| Platform | Trigger | Deploy method | +|----------|---------|---------------| +| `lambda` | Manual invocation | `dotflow deploy` or `sam deploy` | +| `lambda-scheduled` | EventBridge cron/rate | `dotflow deploy --schedule` or `sam deploy` | +| `lambda-s3-trigger` | S3 file upload | `dotflow deploy` or `sam deploy` | +| `lambda-sqs-trigger` | SQS message | `dotflow deploy` or `sam deploy` | +| `lambda-api-trigger` | HTTP POST via API Gateway | `dotflow deploy` or `sam deploy` | + +## Important + +- Do not rename `handler.py` or the `handler()` function — the Dockerfile CMD is `handler.handler` +- Do not rename `workflow.py` or the `main()` function — `handler.py` imports it +- The `samconfig.toml` has stack name, region, and ECR repository pre-configured diff --git a/docs/nav/cloud/nomad.md b/docs/nav/cloud/nomad.md new file mode 100644 index 00000000..6b5075aa --- /dev/null +++ b/docs/nav/cloud/nomad.md @@ -0,0 +1,5 @@ +# Nomad + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/pubsub-trigger.md b/docs/nav/cloud/pubsub-trigger.md new file mode 100644 index 00000000..3dec67c7 --- /dev/null +++ b/docs/nav/cloud/pubsub-trigger.md @@ -0,0 +1,5 @@ +# Pubsub Trigger + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/railway.md b/docs/nav/cloud/railway.md new file mode 100644 index 00000000..69d3e9b6 --- /dev/null +++ b/docs/nav/cloud/railway.md @@ -0,0 +1,5 @@ +# Railway + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/render.md b/docs/nav/cloud/render.md new file mode 100644 index 00000000..c73d18eb --- /dev/null +++ b/docs/nav/cloud/render.md @@ -0,0 +1,5 @@ +# Render + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/step-functions.md b/docs/nav/cloud/step-functions.md new file mode 100644 index 00000000..5cb33588 --- /dev/null +++ b/docs/nav/cloud/step-functions.md @@ -0,0 +1,5 @@ +# Step Functions + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/terraform.md b/docs/nav/cloud/terraform.md new file mode 100644 index 00000000..66946037 --- /dev/null +++ b/docs/nav/cloud/terraform.md @@ -0,0 +1,5 @@ +# Terraform + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/cloud/vercel.md b/docs/nav/cloud/vercel.md new file mode 100644 index 00000000..a920ed4f --- /dev/null +++ b/docs/nav/cloud/vercel.md @@ -0,0 +1,5 @@ +# Vercel + +:soon: **Coming soon** + +This platform is not yet available. Check the [template repository](https://github.com/dotflow-io/template) for updates. diff --git a/docs/nav/concepts/concept-providers.md b/docs/nav/concepts/concept-providers.md index f07baf6e..6c9779de 100644 --- a/docs/nav/concepts/concept-providers.md +++ b/docs/nav/concepts/concept-providers.md @@ -1,26 +1,51 @@ # Providers -**Providers** are pluggable backends wired through [`Config`](../reference/config.md). They let the same workflow code use different storage, notifications, logging, and scheduling without changing task logic. +**Providers** are pluggable backends wired through [`Config`](../reference/config.md). They let the same workflow code use different storage, notifications, logging, tracing, metrics, and scheduling without changing task logic. ## Families -| Provider | Purpose | -|----------|---------| -| **Storage** | Persists task context and checkpoints (memory, file, S3, GCS, …). | -| **Notify** | Sends alerts or summaries (default no-op, Telegram, …). | -| **Log** | Structured logging for runs. | -| **Scheduler** | Drives recurring execution (default, cron with `dotflow[scheduler]`). | - -You pass instances into `Config`, for example `Config(storage=StorageFile(path=".output"), scheduler=SchedulerCron(...))`. Built-in providers use **core** dependencies; cloud and cron integrations often need **pip extras**—see [Use integrations](../integrations/use-integrations.md) and the [Overview](../integrations/index.md) hub. +| Provider | Purpose | Built-in | Integrations | +|----------|---------|----------|--------------| +| **Storage** | Persists task context and checkpoints | Default (memory), File | S3, GCS | +| **Notify** | Sends alerts on status changes | Default (no-op) | Telegram, Discord | +| **Log** | Structured logging for runs | Default (console/file) | OpenTelemetry, Sentry | +| **Tracer** | Distributed traces per workflow/task | Default (no-op) | OpenTelemetry, Sentry | +| **Metrics** | Counters and histograms | Default (no-op) | OpenTelemetry | +| **Scheduler** | Drives recurring execution | Default (no-op) | Cron | + +You pass instances into `Config`: + +```python +from dotflow import Config +from dotflow.providers import StorageFile, LogDefault, TracerOpenTelemetry + +config = Config( + storage=StorageFile(path=".output"), + log=LogDefault(output="console", format="json"), + tracer=TracerOpenTelemetry(service_name="my-pipeline"), +) +``` + +Built-in providers use **core** dependencies. Cloud and observability integrations need **pip extras**: + +| Extra | Install | Providers | +|-------|---------|-----------| +| `dotflow[scheduler]` | croniter | SchedulerCron | +| `dotflow[aws]` | boto3 | StorageS3 | +| `dotflow[gcp]` | google-cloud-storage | StorageGCS | +| `dotflow[otel]` | opentelemetry-api, opentelemetry-sdk | LogOpenTelemetry, TracerOpenTelemetry, MetricsOpenTelemetry | +| `dotflow[sentry]` | sentry-sdk | LogSentry, TracerSentry | ## Why it matters - **Durability**: storage + [checkpoints](../tutorial/checkpoint.md) enable resume after failure. -- **Observability**: log and notify providers surface failures and retries. +- **Observability**: log, tracer, and metrics providers surface failures, retries, and performance. +- **Notifications**: notify providers alert teams via Telegram, Discord, or custom webhooks. - **Operations**: swap file storage in development for S3 or GCS in production with one config change. ## References - [`Config`](../reference/config.md) - [Custom providers](../development/custom-providers.md) -- Abstract bases: [`Storage`](../reference/abc-storage.md), [`Notify`](../reference/abc-notify.md), [`Log`](../reference/abc-log.md), [`Scheduler`](../reference/abc-scheduler.md) +- [Integrations](../integrations/index.md) +- Abstract bases: [`Storage`](../reference/abc-storage.md), [`Notify`](../reference/abc-notify.md), [`Log`](../reference/abc-log.md), [`Scheduler`](../reference/abc-scheduler.md), [`Tracer`](../reference/abc-tracer.md), [`Metrics`](../reference/abc-metrics.md) diff --git a/docs/nav/how-to/index.md b/docs/nav/how-to/index.md index ff9bbd2f..6ff96c5a 100644 --- a/docs/nav/how-to/index.md +++ b/docs/nav/how-to/index.md @@ -2,14 +2,6 @@
-- :material-cog-outline: __Providers__ - - --- - - Connect **storage**, **notify**, **log**, and **scheduler** backends. Submenus group tutorials by provider type. - - [:octicons-arrow-right-24: How-to guides](../tutorial/storage-default.md) - - :material-graph-outline: __Workflows__ --- diff --git a/dotflow/__init__.py b/dotflow/__init__.py index e22e62be..cdfd4e8e 100644 --- a/dotflow/__init__.py +++ b/dotflow/__init__.py @@ -1,6 +1,6 @@ """Dotflow __init__ module.""" -__version__ = "0.14.1.dev2" +__version__ = "0.15.0.dev2" __description__ = "🎲 Dotflow turns an idea into flow!" from .core.action import Action as action diff --git a/dotflow/cli/commands/__init__.py b/dotflow/cli/commands/__init__.py index 1c2a7fc0..0f66e5e1 100644 --- a/dotflow/cli/commands/__init__.py +++ b/dotflow/cli/commands/__init__.py @@ -1,8 +1,18 @@ """Commands __init__ module.""" +from dotflow.cli.commands.cloud import CloudGenerateCommand, CloudListCommand +from dotflow.cli.commands.deploy import DeployCommand from dotflow.cli.commands.init import InitCommand from dotflow.cli.commands.log import LogCommand from dotflow.cli.commands.schedule import ScheduleCommand from dotflow.cli.commands.start import StartCommand -__all__ = ["InitCommand", "LogCommand", "ScheduleCommand", "StartCommand"] +__all__ = [ + "CloudGenerateCommand", + "CloudListCommand", + "DeployCommand", + "InitCommand", + "LogCommand", + "ScheduleCommand", + "StartCommand", +] diff --git a/dotflow/cli/commands/cloud.py b/dotflow/cli/commands/cloud.py new file mode 100644 index 00000000..44916741 --- /dev/null +++ b/dotflow/cli/commands/cloud.py @@ -0,0 +1,201 @@ +"""Command cloud module""" + +import json +import re +from pathlib import Path +from subprocess import run + +from rich import print # type: ignore +from rich.console import Console +from rich.table import Table + +from dotflow.cli.command import Command +from dotflow.settings import Settings as settings + + +def _get_template_dir() -> Path | None: + import tempfile + + target = Path(tempfile.gettempdir()) / "dotflow-template" + cloud_dir = target / settings.TEMPLATE_CLOUD_DIR + + if cloud_dir.exists(): + run( + ["git", "-C", str(target), "pull", "--ff-only"], + capture_output=True, + text=True, + ) + return cloud_dir + + try: + result = run( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + settings.TEMPLATE_BRANCH, + settings.TEMPLATE_REPO, + str(target), + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return cloud_dir + except Exception as err: + print( + settings.ERROR_ALERT, + f"Failed to clone template repository: {err}", + ) + + return None + + +def _load_registry(cloud_dir: Path) -> dict | None: + registry_path = cloud_dir / "registry.json" + if not registry_path.exists(): + print(settings.ERROR_ALERT, "Registry not found in template.") + return None + + try: + return json.loads(registry_path.read_text()) + except Exception as err: + print(settings.ERROR_ALERT, f"Failed to read registry: {err}") + return None + + +def _get_platforms(registry: dict) -> dict | None: + platforms = registry.get("platforms") + if not isinstance(platforms, dict): + print(settings.ERROR_ALERT, "Unexpected registry format.") + return None + + return platforms + + +def _read_project_name(pyproject: Path) -> str | None: + try: + in_project = False + for line in pyproject.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("["): + in_project = stripped in ("[project]", "[tool.poetry]") + if in_project and re.match(r"^name\s*=", stripped): + return stripped.split("=", 1)[1].strip().strip('"').strip("'") + except Exception: + return None + return None + + +class CloudGenerateCommand(Command): + def setup(self): + platform = self.params.platform + + cloud_dir = _get_template_dir() + if cloud_dir is None: + return + + registry = _load_registry(cloud_dir) + if registry is None: + return + + platforms = _get_platforms(registry) + if platforms is None: + return + + if platform not in platforms: + available = ", ".join(sorted(platforms.keys())) + print( + settings.ERROR_ALERT, + f"Unknown platform '{platform}'. Available: {available}", + ) + return + + platform_info = platforms[platform] + files = platform_info.get("files", []) + output = Path(self.params.output).resolve() + output.mkdir(parents=True, exist_ok=True) + + project_name = self.params.project + if not project_name: + pyproject = Path.cwd() / "pyproject.toml" + if pyproject.exists(): + project_name = _read_project_name(pyproject) + if not project_name: + project_name = Path.cwd().name + + module_name = project_name.replace("-", "_") + + print( + settings.INFO_ALERT, + f"Generating {platform_info.get('name', platform)} files" + f" for '{project_name}'...", + ) + + source_dir = cloud_dir / platform + + for filename in files: + filepath = (output / filename).resolve() + try: + filepath.relative_to(output) + except ValueError: + print( + settings.ERROR_ALERT, + f" Skipped (unsafe filename): {filename}", + ) + continue + + if filepath.exists(): + print( + settings.WARNING_ALERT, + f" Skipped (already exists): {filepath}", + ) + continue + + source = source_dir / filename + if not source.exists(): + print( + settings.ERROR_ALERT, + f" Skipped (template not found): {filename}", + ) + continue + + content = source.read_text() + content = content.replace("{{PROJECT_NAME}}", project_name) + content = content.replace("{{MODULE_NAME}}", module_name) + + filepath.write_text(content) + print(f" Created: {filepath}") + + print(settings.INFO_ALERT, "Done.") + + +class CloudListCommand(Command): + def setup(self): + cloud_dir = _get_template_dir() + if cloud_dir is None: + return + + registry = _load_registry(cloud_dir) + if registry is None: + return + + platforms = _get_platforms(registry) + if platforms is None: + return + + table = Table(title="Available Platforms") + table.add_column("Platform", style="bold cyan") + table.add_column("Name", style="bold") + table.add_column("Description") + + for key, info in platforms.items(): + table.add_row( + key, + info.get("name", ""), + info.get("description", ""), + ) + + Console().print(table) diff --git a/dotflow/cli/commands/deploy.py b/dotflow/cli/commands/deploy.py new file mode 100644 index 00000000..1da7f241 --- /dev/null +++ b/dotflow/cli/commands/deploy.py @@ -0,0 +1,110 @@ +"""Command deploy module.""" + +from rich import print # type: ignore + +from dotflow.cli.command import Command +from dotflow.settings import Settings as settings + +SAM_PLATFORMS = { + "ecs-scheduled", +} + +DEFAULT_REGIONS = { + "lambda": "us-east-1", + "lambda-scheduled": "us-east-1", + "lambda-s3-trigger": "us-east-1", + "lambda-sqs-trigger": "us-east-1", + "lambda-api-trigger": "us-east-1", + "ecs": "us-east-1", + "cloud-run": "us-central1", + "cloud-run-scheduled": "us-central1", +} + + +class DeployCommand(Command): + def setup(self): + platform = self.params.platform + name = self.params.project + region = self.params.region or DEFAULT_REGIONS.get( + platform, "us-east-1" + ) + schedule = getattr(self.params, "schedule", None) + + if platform in SAM_PLATFORMS: + print( + settings.INFO_ALERT, + f"'{platform}' requires SAM for full trigger setup.", + ) + print(" Run: sam build && sam deploy") + return + + deployers = { + "lambda": self._deploy_lambda, + "lambda-scheduled": self._deploy_lambda, + "lambda-s3-trigger": self._deploy_lambda_s3, + "lambda-sqs-trigger": self._deploy_lambda_sqs, + "lambda-api-trigger": self._deploy_lambda_api, + "ecs": self._deploy_ecs, + "cloud-run": self._deploy_cloud_run, + "cloud-run-scheduled": self._deploy_cloud_run, + "github-actions": self._deploy_github_actions, + } + + handler = deployers.get(platform) + if not handler: + print( + settings.ERROR_ALERT, + f"Deploy not supported for '{platform}'.", + ) + return + + handler(name=name, region=region, schedule=schedule) + + def _deploy_lambda(self, name: str, region: str, schedule: str = None): + """Deploy to AWS Lambda.""" + from dotflow.cloud.aws import LambdaDeployer + + deployer = LambdaDeployer(region=region) + deployer.deploy(name, schedule=schedule) + + def _deploy_lambda_api(self, name: str, region: str, **kwargs): + """Deploy to AWS Lambda + API Gateway.""" + from dotflow.cloud.aws import LambdaApiDeployer + + deployer = LambdaApiDeployer(region=region) + deployer.deploy(name) + + def _deploy_lambda_s3(self, name: str, region: str, **kwargs): + """Deploy to AWS Lambda + S3 Trigger.""" + from dotflow.cloud.aws import LambdaS3Deployer + + deployer = LambdaS3Deployer(region=region) + deployer.deploy(name) + + def _deploy_lambda_sqs(self, name: str, region: str, **kwargs): + """Deploy to AWS Lambda + SQS Trigger.""" + from dotflow.cloud.aws import LambdaSQSDeployer + + deployer = LambdaSQSDeployer(region=region) + deployer.deploy(name) + + def _deploy_ecs(self, name: str, region: str, **kwargs): + """Deploy to AWS ECS Fargate.""" + from dotflow.cloud.aws import ECSDeployer + + deployer = ECSDeployer(region=region) + deployer.deploy(name) + + def _deploy_cloud_run(self, name: str, region: str, **kwargs): + """Deploy to Google Cloud Run.""" + from dotflow.cloud.gcp import CloudRunDeployer + + deployer = CloudRunDeployer(region=region) + deployer.deploy(name) + + def _deploy_github_actions(self, name: str, **kwargs): + """Deploy to GitHub Actions.""" + from dotflow.cloud.github import ActionsDeployer + + deployer = ActionsDeployer() + deployer.deploy(name) diff --git a/dotflow/cli/setup.py b/dotflow/cli/setup.py index 67f45f5b..3d37be94 100644 --- a/dotflow/cli/setup.py +++ b/dotflow/cli/setup.py @@ -4,6 +4,9 @@ from dotflow import __description__, __version__ from dotflow.cli.commands import ( + CloudGenerateCommand, + CloudListCommand, + DeployCommand, InitCommand, LogCommand, ScheduleCommand, @@ -39,6 +42,8 @@ def __init__(self, parser): self.setup_logs() self.setup_start() self.setup_schedule() + self.setup_cloud() + self.setup_deploy() self.command() def setup_init(self): @@ -145,6 +150,66 @@ def setup_schedule(self): self.cmd_schedule.set_defaults(exec=ScheduleCommand) + def setup_cloud(self): + self.cmd_cloud = self.subparsers.add_parser( + "cloud", + help="Generate cloud infrastructure files for a target platform", + ) + cloud_subparsers = self.cmd_cloud.add_subparsers() + + cmd_generate = cloud_subparsers.add_parser( + "generate", help="Generate infrastructure files" + ) + cmd_generate.add_argument( + "--platform", + required=True, + help="Target platform (e.g. docker, lambda, cloud-run, ecs, kubernetes)", + ) + cmd_generate.add_argument( + "--project", + default=None, + help="Project name (defaults to current directory name)", + ) + cmd_generate.add_argument( + "--output", + default=".", + help="Output directory (defaults to current directory)", + ) + cmd_generate.set_defaults(exec=CloudGenerateCommand) + + cmd_list = cloud_subparsers.add_parser( + "list", help="List available cloud platforms" + ) + cmd_list.set_defaults(exec=CloudListCommand) + + def setup_deploy(self): + self.cmd_deploy = self.subparsers.add_parser( + "deploy", + help="Deploy pipeline to a cloud platform", + ) + self.cmd_deploy.add_argument( + "--platform", + required=True, + help="Target platform (e.g. lambda, ecs)", + ) + self.cmd_deploy.add_argument( + "--project", + default=None, + required=True, + help="Project name", + ) + self.cmd_deploy.add_argument( + "--region", + default=None, + help="Cloud region (e.g. us-east-1 for AWS, us-central1 for GCP)", + ) + self.cmd_deploy.add_argument( + "--schedule", + default=None, + help="Schedule expression (e.g. 'rate(6 hours)')", + ) + self.cmd_deploy.set_defaults(exec=DeployCommand) + def setup_logs(self): self.cmd_logs = self.subparsers.add_parser("logs", help="Logs") self.cmd_logs = self.cmd_logs.add_argument_group( diff --git a/dotflow/cloud/__init__.py b/dotflow/cloud/__init__.py new file mode 100644 index 00000000..4ba0a8c6 --- /dev/null +++ b/dotflow/cloud/__init__.py @@ -0,0 +1 @@ +"""Deploy module.""" diff --git a/dotflow/cloud/aws/__init__.py b/dotflow/cloud/aws/__init__.py new file mode 100644 index 00000000..9d5d2de3 --- /dev/null +++ b/dotflow/cloud/aws/__init__.py @@ -0,0 +1,15 @@ +"""AWS deploy module.""" + +from dotflow.cloud.aws.ecs_deployer import ECSDeployer +from dotflow.cloud.aws.lambda_api_deployer import LambdaApiDeployer +from dotflow.cloud.aws.lambda_deployer import LambdaDeployer +from dotflow.cloud.aws.lambda_s3_deployer import LambdaS3Deployer +from dotflow.cloud.aws.lambda_sqs_deployer import LambdaSQSDeployer + +__all__ = [ + "LambdaDeployer", + "LambdaApiDeployer", + "LambdaS3Deployer", + "LambdaSQSDeployer", + "ECSDeployer", +] diff --git a/dotflow/cloud/aws/base_lambda_deployer.py b/dotflow/cloud/aws/base_lambda_deployer.py new file mode 100644 index 00000000..25402c50 --- /dev/null +++ b/dotflow/cloud/aws/base_lambda_deployer.py @@ -0,0 +1,96 @@ +"""Base Lambda deployment — shared logic for all Lambda variants.""" + +from __future__ import annotations + +from rich import print # type: ignore + +from dotflow.cloud.aws.constants import BOTO3_NOT_FOUND, CREDENTIALS_NOT_FOUND +from dotflow.cloud.aws.services.cloudwatch import CloudWatch +from dotflow.cloud.aws.services.ecr import ECR +from dotflow.cloud.aws.services.iam import IAM +from dotflow.cloud.core import Deployer +from dotflow.settings import Settings as settings + + +class BaseLambdaDeployer(Deployer): + """Shared logic for all Lambda deployers.""" + + def __init__(self, region: str = "us-east-1"): + try: + import boto3 + except ImportError as err: + raise SystemExit(BOTO3_NOT_FOUND) from err + + try: + sts = boto3.client("sts", region_name=region) + self._account_id = sts.get_caller_identity()["Account"] + except Exception as err: + raise SystemExit(CREDENTIALS_NOT_FOUND) from err + + self._region = region + self._boto3 = boto3 + self._lambda = boto3.client("lambda", region_name=region) + + self._ecr = ECR( + boto3.client("ecr", region_name=region), + self._account_id, + region, + ) + self._iam = IAM(boto3.client("iam", region_name=region)) + self._logs = CloudWatch(boto3.client("logs", region_name=region)) + + @property + def _function_arn(self) -> str: + return ( + f"arn:aws:lambda:{self._region}:{self._account_id}" + f":function:{self._name}" + ) + + def setup(self, name: str) -> None: + """Create IAM role and CloudWatch log group.""" + self._name = name + self._role_arn = self._iam.ensure_lambda_role(name) + self._logs.ensure_log_group(f"/aws/lambda/{name}") + + def ensure_roles(self, name: str) -> str: + """Create or get Lambda execution role.""" + return self._iam.ensure_lambda_role(name) + + def ensure_logs(self, name: str) -> None: + """Create CloudWatch log group.""" + self._logs.ensure_log_group(f"/aws/lambda/{name}") + + def deploy(self, name: str, **kwargs) -> None: + """Deploy Lambda and configure trigger.""" + print(settings.INFO_ALERT, f"Deploying '{name}'...") + + self.setup(name) + image_uri = self._ecr.push(name) + self._create_or_update_lambda(name, image_uri, self._role_arn) + self._configure_trigger(name, **kwargs) + + print(settings.INFO_ALERT, "Done.") + + def _create_or_update_lambda( + self, name: str, image_uri: str, role_arn: str + ): + """Create or update Lambda function.""" + try: + self._lambda.get_function(FunctionName=name) + print(" Updating Lambda function...") + self._lambda.update_function_code( + FunctionName=name, ImageUri=image_uri + ) + except self._lambda.exceptions.ResourceNotFoundException: + print(" Creating Lambda function...") + self._lambda.create_function( + FunctionName=name, + PackageType="Image", + Code={"ImageUri": image_uri}, + Role=role_arn, + Timeout=900, + MemorySize=512, + ) + + def _configure_trigger(self, name: str, **kwargs) -> None: + """Override in subclasses to configure the specific trigger.""" diff --git a/dotflow/cloud/aws/constants.py b/dotflow/cloud/aws/constants.py new file mode 100644 index 00000000..bb1f02bf --- /dev/null +++ b/dotflow/cloud/aws/constants.py @@ -0,0 +1,9 @@ +"""AWS messages.""" + +CREDENTIALS_NOT_FOUND = ( + "AWS credentials not found. " + "Run 'aws configure' or set AWS_ACCESS_KEY_ID " + "and AWS_SECRET_ACCESS_KEY environment variables." +) + +BOTO3_NOT_FOUND = "boto3 is required: pip install dotflow[deploy-aws]" diff --git a/dotflow/cloud/aws/ecs_deployer.py b/dotflow/cloud/aws/ecs_deployer.py new file mode 100644 index 00000000..e105ed97 --- /dev/null +++ b/dotflow/cloud/aws/ecs_deployer.py @@ -0,0 +1,108 @@ +"""ECS deployment.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from rich import print # type: ignore + +from dotflow.cloud.aws.constants import BOTO3_NOT_FOUND, CREDENTIALS_NOT_FOUND +from dotflow.cloud.aws.services.cloudwatch import CloudWatch +from dotflow.cloud.aws.services.ecr import ECR +from dotflow.cloud.aws.services.iam import IAM +from dotflow.cloud.core import Deployer +from dotflow.settings import Settings as settings + + +class ECSDeployer(Deployer): + """Deploy dotflow pipelines to AWS ECS Fargate.""" + + def __init__(self, region: str = "us-east-1"): + try: + import boto3 + except ImportError as err: + raise SystemExit(BOTO3_NOT_FOUND) from err + + try: + sts = boto3.client("sts", region_name=region) + self._account_id = sts.get_caller_identity()["Account"] + except Exception as err: + raise SystemExit(CREDENTIALS_NOT_FOUND) from err + + self._region = region + self._ecs = boto3.client("ecs", region_name=region) + + self._ecr = ECR( + boto3.client("ecr", region_name=region), + self._account_id, + region, + ) + self._iam = IAM(boto3.client("iam", region_name=region)) + self._logs = CloudWatch(boto3.client("logs", region_name=region)) + + def setup(self, name: str) -> None: + """Create IAM role, CloudWatch log group, and ECS cluster.""" + self._execution_role_arn = self._iam.ensure_ecs_execution_role() + self._logs.ensure_log_group(f"/ecs/{name}") + self._ensure_cluster(name) + + def deploy(self, name: str, **kwargs) -> None: + """Deploy an ECS Fargate task from the current directory.""" + print(settings.INFO_ALERT, f"Deploying ECS task '{name}'...") + + self.setup(name) + self._ecr.push(name) + + if (Path.cwd() / "task-definition.json").exists(): + self._register_from_file() + else: + self._create_task_definition(name, self._execution_role_arn) + + print(settings.INFO_ALERT, "Done.") + + def _register_from_file(self): + """Register task definition from task-definition.json.""" + content = json.loads((Path.cwd() / "task-definition.json").read_text()) + print(" Registering task definition...") + self._ecs.register_task_definition(**content) + + def _create_task_definition(self, name: str, execution_role_arn: str): + """Create a basic Fargate task definition.""" + image_uri = ( + f"{self._account_id}.dkr.ecr.{self._region}" + f".amazonaws.com/{name}:latest" + ) + print(" Creating task definition...") + self._ecs.register_task_definition( + family=name, + networkMode="awsvpc", + requiresCompatibilities=["FARGATE"], + cpu="256", + memory="512", + executionRoleArn=execution_role_arn, + containerDefinitions=[ + { + "name": name, + "image": image_uri, + "essential": True, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": f"/ecs/{name}", + "awslogs-region": self._region, + "awslogs-stream-prefix": "ecs", + }, + }, + } + ], + ) + + def _ensure_cluster(self, name: str): + """Create ECS cluster if it doesn't exist.""" + cluster_name = f"{name}-cluster" + clusters = self._ecs.describe_clusters(clusters=[cluster_name]) + active = [c for c in clusters["clusters"] if c["status"] == "ACTIVE"] + if not active: + print(f" Creating ECS cluster '{cluster_name}'...") + self._ecs.create_cluster(clusterName=cluster_name) diff --git a/dotflow/cloud/aws/lambda_api_deployer.py b/dotflow/cloud/aws/lambda_api_deployer.py new file mode 100644 index 00000000..ce5387e9 --- /dev/null +++ b/dotflow/cloud/aws/lambda_api_deployer.py @@ -0,0 +1,67 @@ +"""Lambda + API Gateway deployment.""" + +from __future__ import annotations + +import contextlib + +from dotflow.cloud.aws.base_lambda_deployer import BaseLambdaDeployer + + +class LambdaApiDeployer(BaseLambdaDeployer): + """Deploy dotflow pipeline as Lambda + API Gateway.""" + + def _configure_trigger(self, name: str, **kwargs) -> None: + """Create HTTP API with Lambda integration.""" + apigw = self._boto3.client("apigatewayv2", region_name=self._region) + api_name = f"{name}-api" + + api_id = self._find_api(apigw, api_name) + + if not api_id: + print(" Creating API Gateway...") + response = apigw.create_api(Name=api_name, ProtocolType="HTTP") + api_id = response["ApiId"] + + integration = apigw.create_integration( + ApiId=api_id, + IntegrationType="AWS_PROXY", + IntegrationUri=self._function_arn, + PayloadFormatVersion="2.0", + ) + + apigw.create_route( + ApiId=api_id, + RouteKey="POST /workflow", + Target=f"integrations/{integration['IntegrationId']}", + ) + + apigw.create_stage( + ApiId=api_id, StageName="$default", AutoDeploy=True + ) + + with contextlib.suppress( + self._lambda.exceptions.ResourceConflictException + ): + self._lambda.add_permission( + FunctionName=name, + StatementId=f"{api_name}-invoke", + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=( + f"arn:aws:execute-api:{self._region}:" + f"{self._account_id}:{api_id}/*" + ), + ) + + url = ( + f"https://{api_id}.execute-api." + f"{self._region}.amazonaws.com/workflow" + ) + print(f" Endpoint: {url}") + + def _find_api(self, apigw, name: str) -> str | None: + """Find existing API by name.""" + for api in apigw.get_apis().get("Items", []): + if api["Name"] == name: + return api["ApiId"] + return None diff --git a/dotflow/cloud/aws/lambda_deployer.py b/dotflow/cloud/aws/lambda_deployer.py new file mode 100644 index 00000000..e0a2c1db --- /dev/null +++ b/dotflow/cloud/aws/lambda_deployer.py @@ -0,0 +1,49 @@ +"""Lambda deployment.""" + +from __future__ import annotations + +import contextlib + +from dotflow.cloud.aws.base_lambda_deployer import BaseLambdaDeployer + + +class LambdaDeployer(BaseLambdaDeployer): + """Deploy dotflow pipelines to AWS Lambda.""" + + def _configure_trigger(self, name: str, **kwargs) -> None: + """Create EventBridge schedule if provided.""" + schedule = kwargs.get("schedule") + if not schedule: + return + + rule_name = f"{name}-schedule" + + print(f" Creating EventBridge rule '{rule_name}'...") + events = self._boto3.client("events", region_name=self._region) + + events.put_rule( + Name=rule_name, + ScheduleExpression=schedule, + State="ENABLED", + ) + + with contextlib.suppress( + self._lambda.exceptions.ResourceConflictException + ): + self._lambda.add_permission( + FunctionName=name, + StatementId=rule_name, + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=( + f"arn:aws:events:{self._region}:{self._account_id}" + f":rule/{rule_name}" + ), + ) + + events.put_targets( + Rule=rule_name, + Targets=[{"Id": "1", "Arn": self._function_arn}], + ) + + print(f" Schedule: {schedule}") diff --git a/dotflow/cloud/aws/lambda_s3_deployer.py b/dotflow/cloud/aws/lambda_s3_deployer.py new file mode 100644 index 00000000..6595bc11 --- /dev/null +++ b/dotflow/cloud/aws/lambda_s3_deployer.py @@ -0,0 +1,56 @@ +"""Lambda + S3 Trigger deployment.""" + +from __future__ import annotations + +import contextlib + +from dotflow.cloud.aws.base_lambda_deployer import BaseLambdaDeployer + + +class LambdaS3Deployer(BaseLambdaDeployer): + """Deploy dotflow pipeline as Lambda + S3 Trigger.""" + + def _configure_trigger(self, name: str, **kwargs) -> None: + """Create S3 bucket with Lambda notification.""" + s3 = self._boto3.client("s3", region_name=self._region) + bucket_name = f"{name}-source" + + print(f" Creating S3 bucket '{bucket_name}'...") + try: + if self._region == "us-east-1": + s3.create_bucket(Bucket=bucket_name) + else: + s3.create_bucket( + Bucket=bucket_name, + CreateBucketConfiguration={ + "LocationConstraint": self._region + }, + ) + except s3.exceptions.BucketAlreadyOwnedByYou: + pass + + with contextlib.suppress( + self._lambda.exceptions.ResourceConflictException + ): + self._lambda.add_permission( + FunctionName=name, + StatementId=f"{name}-s3-invoke", + Action="lambda:InvokeFunction", + Principal="s3.amazonaws.com", + SourceArn=f"arn:aws:s3:::{bucket_name}", + ) + + print(" Configuring S3 notification...") + s3.put_bucket_notification_configuration( + Bucket=bucket_name, + NotificationConfiguration={ + "LambdaFunctionConfigurations": [ + { + "LambdaFunctionArn": self._function_arn, + "Events": ["s3:ObjectCreated:*"], + } + ] + }, + ) + + print(f" Trigger: s3://{bucket_name}/*") diff --git a/dotflow/cloud/aws/lambda_sqs_deployer.py b/dotflow/cloud/aws/lambda_sqs_deployer.py new file mode 100644 index 00000000..fd848f55 --- /dev/null +++ b/dotflow/cloud/aws/lambda_sqs_deployer.py @@ -0,0 +1,43 @@ +"""Lambda + SQS Trigger deployment.""" + +from __future__ import annotations + +from dotflow.cloud.aws.base_lambda_deployer import BaseLambdaDeployer + + +class LambdaSQSDeployer(BaseLambdaDeployer): + """Deploy dotflow pipeline as Lambda + SQS Trigger.""" + + def _configure_trigger(self, name: str, **kwargs) -> None: + """Create SQS queue and event source mapping.""" + sqs = self._boto3.client("sqs", region_name=self._region) + queue_name = f"{name}-queue" + + print(f" Creating SQS queue '{queue_name}'...") + try: + response = sqs.create_queue( + QueueName=queue_name, + Attributes={"VisibilityTimeout": "960"}, + ) + queue_url = response["QueueUrl"] + except sqs.exceptions.QueueNameAlreadyExists: + queue_url = sqs.get_queue_url(QueueName=queue_name)["QueueUrl"] + + queue_arn = sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + existing = self._lambda.list_event_source_mappings( + FunctionName=name, EventSourceArn=queue_arn + )["EventSourceMappings"] + + if not existing: + print(" Creating event source mapping...") + self._lambda.create_event_source_mapping( + FunctionName=name, + EventSourceArn=queue_arn, + BatchSize=1, + Enabled=True, + ) + + print(f" Queue: {queue_url}") diff --git a/dotflow/cloud/aws/services/__init__.py b/dotflow/cloud/aws/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dotflow/cloud/aws/services/cloudwatch.py b/dotflow/cloud/aws/services/cloudwatch.py new file mode 100644 index 00000000..d9045556 --- /dev/null +++ b/dotflow/cloud/aws/services/cloudwatch.py @@ -0,0 +1,22 @@ +"""CloudWatch Logs operations.""" + +from __future__ import annotations + +import contextlib + +from dotflow.cloud.core import LogManager + + +class CloudWatch(LogManager): + """AWS CloudWatch log manager.""" + + def __init__(self, logs_client): + self._logs = logs_client + + def ensure_log_group(self, name: str): + """Create log group if it doesn't exist.""" + with contextlib.suppress( + self._logs.exceptions.ResourceAlreadyExistsException + ): + self._logs.create_log_group(logGroupName=name) + print(f" Created log group '{name}'") diff --git a/dotflow/cloud/aws/services/ecr.py b/dotflow/cloud/aws/services/ecr.py new file mode 100644 index 00000000..fb721d90 --- /dev/null +++ b/dotflow/cloud/aws/services/ecr.py @@ -0,0 +1,72 @@ +"""ECR operations — build, push and authenticate Docker images.""" + +from __future__ import annotations + +import base64 +import contextlib +from subprocess import run + +from dotflow.cloud.core import Registry + + +class ECR(Registry): + """Amazon ECR container registry.""" + + def __init__(self, ecr_client, account_id: str, region: str): + self._ecr = ecr_client + self._account_id = account_id + self._region = region + + def push(self, name: str) -> str: + """Build, tag and push a Docker image. Returns the image URI.""" + self._ensure_repository(name) + self.login() + + image_uri = ( + f"{self._account_id}.dkr.ecr.{self._region}" + f".amazonaws.com/{name}:latest" + ) + + print(" Building image...") + run(["docker", "build", "-t", name, "."], check=True) + + print(" Tagging image...") + run(["docker", "tag", f"{name}:latest", image_uri], check=True) + + print(" Pushing image...") + run(["docker", "push", image_uri], check=True) + + return image_uri + + def _ensure_repository(self, name: str): + """Create ECR repository if it doesn't exist.""" + print(" Creating ECR repository...") + with contextlib.suppress( + self._ecr.exceptions.RepositoryAlreadyExistsException + ): + self._ecr.create_repository(repositoryName=name) + + def login(self): + """Authenticate Docker with ECR.""" + print(" Logging in to ECR...") + token = self._ecr.get_authorization_token() + auth = token["authorizationData"][0] + registry = auth["proxyEndpoint"] + username, password = ( + base64.b64decode(auth["authorizationToken"]).decode().split(":") + ) + + run( + [ + "docker", + "login", + "--username", + username, + "--password-stdin", + registry, + ], + input=password, + text=True, + capture_output=True, + check=True, + ) diff --git a/dotflow/cloud/aws/services/iam.py b/dotflow/cloud/aws/services/iam.py new file mode 100644 index 00000000..46307b5a --- /dev/null +++ b/dotflow/cloud/aws/services/iam.py @@ -0,0 +1,79 @@ +"""IAM operations — create and manage execution roles.""" + +from __future__ import annotations + +import json +import time + +from dotflow.cloud.core import RoleManager + + +class IAM(RoleManager): + """AWS IAM role manager.""" + + def __init__(self, iam_client): + self._iam = iam_client + + def ensure_lambda_role(self, name: str) -> str: + """Create or get Lambda execution role.""" + return self.ensure_role( + name=f"{name}-lambda-role", + service="lambda.amazonaws.com", + policy=( + "arn:aws:iam::aws:policy/service-role/" + "AWSLambdaBasicExecutionRole" + ), + wait=True, + ) + + def ensure_ecs_execution_role(self) -> str: + """Create or get ECS task execution role.""" + return self.ensure_role( + name="ecsTaskExecutionRole", + service="ecs-tasks.amazonaws.com", + policy=( + "arn:aws:iam::aws:policy/service-role/" + "AmazonECSTaskExecutionRolePolicy" + ), + ) + + def ensure_role( + self, + name: str, + service: str, + policy: str, + wait: bool = False, + ) -> str: + """Create role if it doesn't exist, attach policy, return ARN.""" + role_name = name + try: + response = self._iam.get_role(RoleName=role_name) + return response["Role"]["Arn"] + except self._iam.exceptions.NoSuchEntityException: + pass + + print(f" Creating IAM role '{role_name}'...") + response = self._iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": service}, + "Action": "sts:AssumeRole", + } + ], + } + ), + ) + self._iam.attach_role_policy( + RoleName=role_name, + PolicyArn=policy, + ) + + if wait: + time.sleep(10) + + return response["Role"]["Arn"] diff --git a/dotflow/cloud/aws/services/s3.py b/dotflow/cloud/aws/services/s3.py new file mode 100644 index 00000000..afe6540f --- /dev/null +++ b/dotflow/cloud/aws/services/s3.py @@ -0,0 +1,47 @@ +"""S3 operations — read and write objects.""" + +from __future__ import annotations + +from json import dumps, loads + +from dotflow.cloud.core import ObjectStorage +from dotflow.core.exception import ModuleNotFound + + +class S3(ObjectStorage): + """Amazon S3 object storage.""" + + def __init__(self, bucket: str, prefix: str = "", region: str = None): + try: + import boto3 + except ImportError: + raise ModuleNotFound( + module="boto3", library="dotflow[aws]" + ) from None + + self._s3 = boto3.client("s3", region_name=region) + self.bucket = bucket + self.prefix = prefix + + self._s3.head_bucket(Bucket=self.bucket) + + def read(self, key: str) -> list: + """Read a JSON list from S3. Returns empty list if key not found.""" + try: + response = self._s3.get_object( + Bucket=self.bucket, + Key=f"{self.prefix}{key}", + ) + data = response["Body"].read().decode("utf-8") + return loads(data) + except self._s3.exceptions.NoSuchKey: + return [] + + def write(self, key: str, data: list) -> None: + """Write a JSON list to S3.""" + self._s3.put_object( + Bucket=self.bucket, + Key=f"{self.prefix}{key}", + Body=dumps(data), + ContentType="application/json", + ) diff --git a/dotflow/cloud/core.py b/dotflow/cloud/core.py new file mode 100644 index 00000000..f6c84a8e --- /dev/null +++ b/dotflow/cloud/core.py @@ -0,0 +1,89 @@ +"""Deploy ABCs.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class Registry(ABC): + """Base class for container registries (ECR, Artifact Registry, Docker Hub, etc.).""" + + @abstractmethod + def push(self, name: str) -> str: + """Build and push the Docker image. Returns the image URI.""" + + @abstractmethod + def login(self) -> None: + """Authenticate with the registry.""" + + +class RoleManager(ABC): + """Base class for IAM/service account management.""" + + @abstractmethod + def ensure_role(self, name: str, service: str, policy: str) -> str: + """Create or get a role for the given service. Returns the role ARN/ID.""" + + +class LogManager(ABC): + """Base class for log group/resource management.""" + + @abstractmethod + def ensure_log_group(self, name: str) -> None: + """Create log group if it doesn't exist.""" + + +class ObjectStorage(ABC): + """Base class for object storage (S3, GCS, Azure Blob, etc.).""" + + @abstractmethod + def read(self, key: str) -> list: + """Read data from a key. Returns empty list if not found.""" + + @abstractmethod + def write(self, key: str, data: list) -> None: + """Write data to a key.""" + + +class Queue(ABC): + """Base class for message queues (SQS, Pub/Sub, Azure Queue, etc.).""" + + @abstractmethod + def create(self, name: str) -> str: + """Create queue. Returns queue URL/ID.""" + + @abstractmethod + def get_arn(self, name: str) -> str: + """Get queue ARN/ID.""" + + +class EventScheduler(ABC): + """Base class for event schedulers (EventBridge, Cloud Scheduler, etc.).""" + + @abstractmethod + def create_schedule( + self, name: str, expression: str, target_arn: str + ) -> None: + """Create a scheduled rule targeting a resource.""" + + +class Deployer(ABC): + """Base class for all cloud deployers.""" + + @abstractmethod + def deploy(self, name: str, **kwargs) -> None: + """Deploy the pipeline to the target platform.""" + + def setup(self, name: str) -> None: + """Create required cloud resources (roles, log groups, etc.). + + Optional — override when the platform needs pre-deploy setup. + No-op by default. + """ + + def teardown(self, name: str) -> None: + """Remove cloud resources created by deploy. + + Optional — override when the platform supports cleanup. + No-op by default. + """ diff --git a/dotflow/cloud/gcp/__init__.py b/dotflow/cloud/gcp/__init__.py new file mode 100644 index 00000000..da4b1276 --- /dev/null +++ b/dotflow/cloud/gcp/__init__.py @@ -0,0 +1,5 @@ +"""GCP cloud module.""" + +from dotflow.cloud.gcp.cloudrun_deployer import CloudRunDeployer + +__all__ = ["CloudRunDeployer"] diff --git a/dotflow/cloud/gcp/cloudrun_deployer.py b/dotflow/cloud/gcp/cloudrun_deployer.py new file mode 100644 index 00000000..93121001 --- /dev/null +++ b/dotflow/cloud/gcp/cloudrun_deployer.py @@ -0,0 +1,111 @@ +"""Cloud Run deployment.""" + +from __future__ import annotations + +from rich import print # type: ignore + +from dotflow.cloud.core import Deployer +from dotflow.cloud.gcp.constants import ( + CREDENTIALS_NOT_FOUND, + GCP_SDK_NOT_FOUND, +) +from dotflow.cloud.gcp.services.apis import APIs +from dotflow.cloud.gcp.services.artifact_registry import ArtifactRegistry +from dotflow.settings import Settings as settings + + +class CloudRunDeployer(Deployer): + """Deploy dotflow pipelines to Google Cloud Run.""" + + def __init__(self, region: str = "us-central1", project: str = None): + try: + from google.api_core import exceptions + from google.cloud import run_v2 + except ImportError as err: + raise SystemExit(GCP_SDK_NOT_FOUND) from err + + self._run_v2 = run_v2 + self._exceptions = exceptions + self._region = region + + if project: + self._project = project + else: + try: + import google.auth + + _, self._project = google.auth.default() + except Exception as err: + raise SystemExit(CREDENTIALS_NOT_FOUND) from err + + if not self._project: + raise SystemExit(CREDENTIALS_NOT_FOUND) + + self._registry = ArtifactRegistry( + project=self._project, region=self._region + ) + + def setup(self, name: str) -> None: + """Enable required GCP APIs and ensure Cloud Build service account.""" + APIs(self._project).enable() + self._ensure_cloudbuild_sa() + + def _ensure_cloudbuild_sa(self): + """Ensure Cloud Build service identity exists.""" + try: + from google.cloud import service_usage_v1 + + print(" Ensuring Cloud Build service account...") + client = service_usage_v1.ServiceUsageClient() + client.generate_service_identity( + request={ + "parent": f"projects/{self._project}/services/cloudbuild.googleapis.com" + } + ) + except Exception: + pass + + def ensure_roles(self, name: str) -> str: + """No-op — Cloud Run manages service accounts automatically.""" + return "" + + def ensure_logs(self, name: str) -> None: + """No-op — Cloud Logging is automatic.""" + + def deploy(self, name: str, **kwargs) -> None: + """Deploy to Cloud Run via Cloud Build + Run SDK.""" + print(settings.INFO_ALERT, f"Deploying Cloud Run '{name}'...") + + self.setup(name) + image = self._registry.push(name) + self._deploy_service(name, image) + + print(settings.INFO_ALERT, "Done.") + + def _deploy_service(self, name: str, image: str): + """Create or update Cloud Run service.""" + print(" Deploying service...") + + client = self._run_v2.ServicesClient() + parent = f"projects/{self._project}/locations/{self._region}" + + service = self._run_v2.Service( + template=self._run_v2.RevisionTemplate( + containers=[ + self._run_v2.Container(image=image), + ], + ), + ) + + try: + request = self._run_v2.CreateServiceRequest( + parent=parent, service=service, service_id=name + ) + operation = client.create_service(request=request) + operation.result() + except self._exceptions.AlreadyExists: + print(" Updating existing service...") + service.name = f"{parent}/services/{name}" + request = self._run_v2.UpdateServiceRequest(service=service) + operation = client.update_service(request=request) + operation.result() diff --git a/dotflow/cloud/gcp/constants.py b/dotflow/cloud/gcp/constants.py new file mode 100644 index 00000000..22c5f348 --- /dev/null +++ b/dotflow/cloud/gcp/constants.py @@ -0,0 +1,11 @@ +"""GCP constants.""" + +GCP_SDK_NOT_FOUND = ( + "google-cloud-run is required: pip install dotflow[deploy-gcp]" +) + +CREDENTIALS_NOT_FOUND = ( + "GCP credentials not found. " + "Run 'gcloud auth application-default login' " + "or set GOOGLE_APPLICATION_CREDENTIALS environment variable." +) diff --git a/dotflow/cloud/gcp/services/__init__.py b/dotflow/cloud/gcp/services/__init__.py new file mode 100644 index 00000000..753b2638 --- /dev/null +++ b/dotflow/cloud/gcp/services/__init__.py @@ -0,0 +1 @@ +"""GCP services module.""" diff --git a/dotflow/cloud/gcp/services/apis.py b/dotflow/cloud/gcp/services/apis.py new file mode 100644 index 00000000..f9f46111 --- /dev/null +++ b/dotflow/cloud/gcp/services/apis.py @@ -0,0 +1,39 @@ +"""GCP API enablement.""" + +from __future__ import annotations + +from dotflow.core.exception import ModuleNotFound + + +class APIs: + """Enable GCP APIs via Service Usage SDK.""" + + REQUIRED = [ + "cloudbuild.googleapis.com", + "artifactregistry.googleapis.com", + "run.googleapis.com", + ] + + def __init__(self, project: str): + try: + from google.cloud import service_usage_v1 + except ImportError: + raise ModuleNotFound( + module="google-cloud-service-usage", + library="dotflow[deploy-gcp]", + ) from None + + self._client = service_usage_v1.ServiceUsageClient() + self._project = project + + def enable(self): + """Enable all required APIs.""" + print(" Enabling APIs...") + for api in self.REQUIRED: + name = f"projects/{self._project}/services/{api}" + try: + self._client.enable_service(name=name).result() + except Exception as err: + if "already enabled" in str(err).lower(): + continue + print(f" Warning: Failed to enable {api}: {err}") diff --git a/dotflow/cloud/gcp/services/artifact_registry.py b/dotflow/cloud/gcp/services/artifact_registry.py new file mode 100644 index 00000000..891162e6 --- /dev/null +++ b/dotflow/cloud/gcp/services/artifact_registry.py @@ -0,0 +1,127 @@ +"""Artifact Registry operations — build and push Docker images.""" + +from __future__ import annotations + +import io +import os +import tarfile + +from dotflow.cloud.core import Registry +from dotflow.core.exception import ModuleNotFound + + +class ArtifactRegistry(Registry): + """Google Artifact Registry container registry.""" + + def __init__(self, project: str, region: str): + try: + from google.cloud import artifactregistry_v1 + from google.cloud import storage as gcs + from google.cloud.devtools import cloudbuild_v1 + except ImportError: + raise ModuleNotFound( + module="google-cloud-build", + library="dotflow[deploy-gcp]", + ) from None + + self._cloudbuild = cloudbuild_v1 + self._ar = artifactregistry_v1 + self._gcs = gcs + self._project = project + self._region = region + self._bucket_name = f"{project}_cloudbuild" + self._image_base = ( + f"{region}-docker.pkg.dev/{project}/cloud-run-source-deploy" + ) + + def login(self) -> None: + """No-op — Cloud Build handles authentication.""" + + def push(self, name: str) -> str: + """Build and push Docker image via Cloud Build. Returns image URI.""" + image = f"{self._image_base}/{name}:latest" + object_name = f"source/{name}.tar.gz" + + self._ensure_repository(name) + self._upload_source(object_name) + self._build(image, object_name) + + return image + + def _ensure_repository(self, name: str): + """Create Artifact Registry Docker repository if it doesn't exist.""" + print(" Ensuring Artifact Registry repository...") + + client = self._ar.ArtifactRegistryClient() + parent = f"projects/{self._project}/locations/{self._region}" + repo_name = "cloud-run-source-deploy" + + try: + client.get_repository(name=f"{parent}/repositories/{repo_name}") + except Exception: + client.create_repository( + parent=parent, + repository=self._ar.Repository( + format_=self._ar.Repository.Format.DOCKER, + description=f"Docker images for {name}", + ), + repository_id=repo_name, + ).result() + + def _upload_source(self, object_name: str): + """Tar the current directory and upload to GCS.""" + print(" Uploading source...") + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for entry in os.listdir("."): + if entry.startswith("."): + continue + tar.add(entry) + buf.seek(0) + + client = self._gcs.Client(project=self._project) + bucket = client.bucket(self._bucket_name) + + try: + bucket.reload() + except Exception: + bucket.create(location=self._region) + + blob = bucket.blob(object_name) + blob.upload_from_file(buf, content_type="application/gzip") + + def _build(self, image: str, object_name: str): + """Build Docker image via Cloud Build.""" + print(" Building and pushing image via Cloud Build...") + + client = self._cloudbuild.CloudBuildClient() + + build = self._cloudbuild.Build( + source=self._cloudbuild.Source( + storage_source=self._cloudbuild.StorageSource( + bucket=self._bucket_name, + object_=object_name, + ) + ), + steps=[ + self._cloudbuild.BuildStep( + name="gcr.io/cloud-builders/docker", + args=["build", "-t", image, "."], + ), + ], + images=[image], + ) + + try: + operation = client.create_build( + project_id=self._project, + build=build, + ) + print(f" Build started: {operation.metadata.build.id}") + result = operation.result(timeout=600) + print(f" Build status: {result.status.name}") + except Exception as err: + raise SystemExit( + f"Cloud Build failed: {type(err).__name__}: {err}" + ) from err diff --git a/dotflow/cloud/gcp/services/gcs.py b/dotflow/cloud/gcp/services/gcs.py new file mode 100644 index 00000000..855449a1 --- /dev/null +++ b/dotflow/cloud/gcp/services/gcs.py @@ -0,0 +1,45 @@ +"""GCS operations — read and write objects.""" + +from __future__ import annotations + +from json import dumps, loads + +from dotflow.cloud.core import ObjectStorage +from dotflow.core.exception import ModuleNotFound + + +class GCS(ObjectStorage): + """Google Cloud Storage object storage.""" + + def __init__(self, bucket: str, prefix: str = "", project: str = None): + try: + from google.api_core.exceptions import NotFound + from google.cloud import storage as gcs + except ImportError: + raise ModuleNotFound( + module="google-cloud-storage", + library="dotflow[gcp]", + ) from None + + self._not_found = NotFound + self._client = gcs.Client(project=project) + self._bucket = self._client.bucket(bucket) + self._bucket.reload() + self.prefix = prefix + + def read(self, key: str) -> list: + """Read a JSON list from GCS. Returns empty list if key not found.""" + blob = self._bucket.blob(f"{self.prefix}{key}") + try: + data = blob.download_as_text() + return loads(data) + except self._not_found: + return [] + + def write(self, key: str, data: list) -> None: + """Write a JSON list to GCS.""" + blob = self._bucket.blob(f"{self.prefix}{key}") + blob.upload_from_string( + dumps(data), + content_type="application/json", + ) diff --git a/dotflow/cloud/github/__init__.py b/dotflow/cloud/github/__init__.py new file mode 100644 index 00000000..661313da --- /dev/null +++ b/dotflow/cloud/github/__init__.py @@ -0,0 +1,5 @@ +"""GitHub cloud module.""" + +from dotflow.cloud.github.actions_deployer import ActionsDeployer + +__all__ = ["ActionsDeployer"] diff --git a/dotflow/cloud/github/actions_deployer.py b/dotflow/cloud/github/actions_deployer.py new file mode 100644 index 00000000..1059bab9 --- /dev/null +++ b/dotflow/cloud/github/actions_deployer.py @@ -0,0 +1,116 @@ +"""GitHub Actions deployment.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from rich import print # type: ignore + +from dotflow.cloud.core import Deployer +from dotflow.cloud.github.constants import PYGITHUB_NOT_FOUND, TOKEN_NOT_FOUND +from dotflow.settings import Settings as settings + + +class ActionsDeployer(Deployer): + """Deploy dotflow pipeline to GitHub Actions.""" + + def __init__(self, token: str = None): + try: + from github import Github, GithubException + except ImportError as err: + raise SystemExit(PYGITHUB_NOT_FOUND) from err + + self._token = token or os.environ.get("GITHUB_TOKEN") + if not self._token: + raise SystemExit(TOKEN_NOT_FOUND) + + self._github = Github(self._token) + self._GithubException = GithubException + self._user = self._github.get_user() + + def setup(self, name: str) -> None: + """No-op — GitHub manages everything.""" + + def ensure_roles(self, name: str) -> str: + """No-op.""" + return "" + + def ensure_logs(self, name: str) -> None: + """No-op.""" + + def deploy(self, name: str, **kwargs) -> None: + """Create repo and push all project files.""" + print(settings.INFO_ALERT, f"Deploying to GitHub Actions '{name}'...") + + repo = self._create_or_get_repo(name) + self._push_files(repo) + + print(f" Repository: {repo.html_url}") + print(f" Actions: {repo.html_url}/actions") + print(settings.INFO_ALERT, "Done.") + + def _create_or_get_repo(self, name: str): + """Create repo or get existing.""" + try: + repo = self._user.get_repo(name) + print(f" Using existing repo '{name}'") + return repo + except self._GithubException: + pass + + print(f" Creating repo '{name}'...") + return self._user.create_repo( + name=name, + private=True, + auto_init=False, + ) + + def _push_files(self, repo): + """Push all project files to the repo.""" + print(" Pushing files...") + + project_dir = Path.cwd() + tracked = self._get_tracked_files(project_dir) + count = 0 + + for relative in tracked: + filepath = project_dir / relative + + try: + content = filepath.read_text() + except UnicodeDecodeError: + continue + + self._create_or_update_file(repo, relative, content) + count += 1 + + print(f" Pushed {count} files") + + def _get_tracked_files(self, project_dir: Path) -> list[str]: + """Get files that git would track, respecting .gitignore.""" + from git import Repo + + repo = Repo(project_dir) + tracked = set(repo.git.ls_files("--cached").splitlines()) + untracked = set( + repo.git.ls_files("--others", "--exclude-standard").splitlines() + ) + return sorted(tracked | untracked) + + def _create_or_update_file(self, repo, path: str, content: str): + """Create or update a single file in the repo.""" + try: + existing = repo.get_contents(path) + repo.update_file( + path=path, + message=f"update {path}", + content=content, + sha=existing.sha, + ) + except self._GithubException: + repo.create_file( + path=path, + message=f"add {path}", + content=content, + ) diff --git a/dotflow/cloud/github/constants.py b/dotflow/cloud/github/constants.py new file mode 100644 index 00000000..104fe965 --- /dev/null +++ b/dotflow/cloud/github/constants.py @@ -0,0 +1,8 @@ +"""GitHub constants.""" + +PYGITHUB_NOT_FOUND = "PyGithub is required: pip install dotflow[deploy-github]" + +TOKEN_NOT_FOUND = ( + "GitHub token not found. " + "Set GITHUB_TOKEN environment variable or pass --token." +) diff --git a/dotflow/logging.py b/dotflow/logging.py index ffb5d7ee..6d3b42ef 100644 --- a/dotflow/logging.py +++ b/dotflow/logging.py @@ -1,27 +1,14 @@ """Log""" import logging -import logging.config from dotflow.settings import Settings as settings -settings.START_PATH.mkdir(parents=True, exist_ok=True) - -logging.basicConfig( - filename=settings.LOG_PATH, - format=settings.LOG_FORMAT, - level=logging.INFO, - filemode="a", -) - logger = logging.getLogger(settings.LOG_PROFILE) logger.setLevel(logging.INFO) ch = logging.StreamHandler() ch.setLevel(logging.INFO) - -formatter = logging.Formatter(settings.LOG_FORMAT) - -ch.setFormatter(formatter) +ch.setFormatter(logging.Formatter(settings.LOG_FORMAT)) logger.addHandler(ch) diff --git a/dotflow/providers/storage_gcs.py b/dotflow/providers/storage_gcs.py index e39b1bf6..269c604b 100644 --- a/dotflow/providers/storage_gcs.py +++ b/dotflow/providers/storage_gcs.py @@ -5,8 +5,8 @@ from typing import Any from dotflow.abc.storage import Storage +from dotflow.cloud.gcp.services.gcs import GCS from dotflow.core.context import Context -from dotflow.core.exception import ModuleNotFound class StorageGCS(Storage): @@ -46,20 +46,7 @@ def __init__( project: str = None, **kwargs, ): - try: - from google.api_core.exceptions import NotFound - from google.cloud import storage as gcs - except ImportError: - raise ModuleNotFound( - module="google-cloud-storage", - library="dotflow[gcp]", - ) from None - - self._not_found = NotFound - self.client = gcs.Client(project=project) - self.bucket_obj = self.client.bucket(bucket) - self.bucket_obj.reload() - self.prefix = prefix + self._gcs = GCS(bucket=bucket, prefix=prefix, project=project) def post(self, key: str, context: Context) -> None: task_context = [] @@ -71,10 +58,10 @@ def post(self, key: str, context: Context) -> None: else: task_context.append(self._dumps(storage=context.storage)) - self._write(key=key, data=task_context) + self._gcs.write(key=key, data=task_context) def get(self, key: str) -> Context: - task_context = self._read(key) + task_context = self._gcs.read(key) if len(task_context) == 0: return Context() @@ -91,21 +78,6 @@ def get(self, key: str) -> Context: def key(self, task: Callable): return f"{task.workflow_id}-{task.task_id}" - def _read(self, key: str) -> list: - blob = self.bucket_obj.blob(f"{self.prefix}{key}") - try: - data = blob.download_as_text() - return loads(data) - except self._not_found: - return [] - - def _write(self, key: str, data: list) -> None: - blob = self.bucket_obj.blob(f"{self.prefix}{key}") - blob.upload_from_string( - dumps(data), - content_type="application/json", - ) - def _loads(self, storage: Any) -> Context: try: return Context(storage=loads(storage)) diff --git a/dotflow/providers/storage_s3.py b/dotflow/providers/storage_s3.py index c9733017..4589a27c 100644 --- a/dotflow/providers/storage_s3.py +++ b/dotflow/providers/storage_s3.py @@ -5,8 +5,8 @@ from typing import Any from dotflow.abc.storage import Storage +from dotflow.cloud.aws.services.s3 import S3 from dotflow.core.context import Context -from dotflow.core.exception import ModuleNotFound class StorageS3(Storage): @@ -46,18 +46,7 @@ def __init__( region: str = None, **kwargs, ): - try: - import boto3 - except ImportError: - raise ModuleNotFound( - module="boto3", library="dotflow[aws]" - ) from None - - self.s3 = boto3.client("s3", region_name=region) - self.bucket = bucket - self.prefix = prefix - - self.s3.head_bucket(Bucket=self.bucket) + self._s3 = S3(bucket=bucket, prefix=prefix, region=region) def post(self, key: str, context: Context) -> None: task_context = [] @@ -69,10 +58,10 @@ def post(self, key: str, context: Context) -> None: else: task_context.append(self._dumps(storage=context.storage)) - self._write(key=key, data=task_context) + self._s3.write(key=key, data=task_context) def get(self, key: str) -> Context: - task_context = self._read(key) + task_context = self._s3.read(key) if len(task_context) == 0: return Context() @@ -89,25 +78,6 @@ def get(self, key: str) -> Context: def key(self, task: Callable): return f"{task.workflow_id}-{task.task_id}" - def _read(self, key: str) -> list: - try: - response = self.s3.get_object( - Bucket=self.bucket, - Key=f"{self.prefix}{key}", - ) - data = response["Body"].read().decode("utf-8") - return loads(data) - except self.s3.exceptions.NoSuchKey: - return [] - - def _write(self, key: str, data: list) -> None: - self.s3.put_object( - Bucket=self.bucket, - Key=f"{self.prefix}{key}", - Body=dumps(data), - ContentType="application/json", - ) - def _loads(self, storage: Any) -> Context: try: return Context(storage=loads(storage)) diff --git a/dotflow/settings.py b/dotflow/settings.py index f75df4be..00e53347 100644 --- a/dotflow/settings.py +++ b/dotflow/settings.py @@ -1,19 +1,26 @@ """Settings""" +import os from pathlib import Path +_OUTPUT = Path(os.environ.get("DOTFLOW_OUTPUT_PATH", ".output")) + class Settings: """Settings DotFlow""" - START_PATH = Path(".output") + START_PATH = _OUTPUT GITIGNORE = Path(".gitignore") LOG_PROFILE = "dotflow" LOG_FILE_NAME = Path("flow.log") - LOG_PATH = Path(".output/flow.log") + LOG_PATH = _OUTPUT / "flow.log" LOG_FORMAT = "%(asctime)s - %(levelname)s [%(name)s]: %(message)s" + TEMPLATE_REPO = "https://github.com/dotflow-io/template.git" + TEMPLATE_BRANCH = "master" + TEMPLATE_CLOUD_DIR = "cloud" + ICON = ":game_die:" ERROR_ALERT = f"{ICON} [bold red]Error:[/bold red]" INFO_ALERT = f"{ICON} [bold blue]Info:[/bold blue]" diff --git a/mkdocs.yml b/mkdocs.yml index 58f749ca..c1c0166a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -222,30 +222,6 @@ nav: - nav/concepts/process-mode-parallel-group.md - How-to guides: - Overview: nav/how-to/index.md - - Providers: - - Storage: - - nav/tutorial/storage-default.md - - nav/tutorial/storage-file.md - - nav/tutorial/storage-s3.md - - nav/tutorial/storage-gcs.md - - Notify: - - nav/tutorial/notify-default.md - - nav/tutorial/notify-telegram.md - - nav/tutorial/notify-discord.md - - Log: - - nav/tutorial/log-default.md - - nav/tutorial/log-opentelemetry.md - - nav/tutorial/log-sentry.md - - Scheduler: - - nav/tutorial/scheduler-default.md - - nav/tutorial/scheduler-cron.md - - Tracer: - - nav/tutorial/tracer-default.md - - nav/tutorial/tracer-opentelemetry.md - - nav/tutorial/tracer-sentry.md - - Metrics: - - nav/tutorial/metrics-default.md - - nav/tutorial/metrics-opentelemetry.md - Workflows: - nav/tutorial/install.md - nav/tutorial/first-steps.md @@ -302,6 +278,25 @@ nav: - nav/tutorial/log-default.md - nav/tutorial/scheduler-default.md - nav/tutorial/scheduler-cron.md + - Cloud: + - Overview: nav/cloud/index.md + - Docker: + - nav/cloud/docker.md + - AWS: + - nav/cloud/lambda.md + - nav/cloud/lambda-scheduled.md + - nav/cloud/lambda-s3-trigger.md + - nav/cloud/lambda-sqs-trigger.md + - nav/cloud/lambda-api-trigger.md + - nav/cloud/ecs.md + - nav/cloud/ecs-scheduled.md + - GCP: + - nav/cloud/cloud-run.md + - nav/cloud/cloud-run-scheduled.md + - Kubernetes: + - nav/cloud/kubernetes.md + - CI/CD: + - nav/cloud/github-actions.md - Advanced: - nav/advanced/scheduled-pipeline-with-resume.md - nav/advanced/parallel-with-groups.md diff --git a/poetry.lock b/poetry.lock index 3cdc17a5..f451a3f8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -308,7 +308,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {main = "platform_python_implementation != \"PyPy\" and extra == \"gcp\"", dev = "platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\""} +markers = {main = "platform_python_implementation != \"PyPy\" and (extra == \"gcp\" or extra == \"deploy-gcp\" or extra == \"deploy-github\")", dev = "platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -852,7 +852,7 @@ files = [ {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] -markers = {main = "python_version < \"3.10\" and extra == \"gcp\"", dev = "python_version < \"3.10\""} +markers = {main = "python_version < \"3.10\" and (extra == \"gcp\" or extra == \"deploy-gcp\" or extra == \"deploy-github\")", dev = "python_version < \"3.10\""} [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} @@ -925,7 +925,7 @@ files = [ {file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"}, {file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"}, ] -markers = {main = "python_version >= \"3.10\" and extra == \"gcp\"", dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"gcp\" or extra == \"deploy-gcp\" or extra == \"deploy-github\")", dev = "python_version >= \"3.10\""} [package.dependencies] cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} @@ -1068,7 +1068,7 @@ description = "Google API client core library" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594"}, {file = "google_api_core-2.30.2.tar.gz", hash = "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9"}, @@ -1077,9 +1077,19 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.0" googleapis-common-protos = ">=1.63.2,<2.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, +] +grpcio-status = [ + {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""}, +] proto-plus = [ {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] protobuf = ">=4.25.8,<8.0.0" requests = ">=2.20.0,<3.0.0" @@ -1095,7 +1105,7 @@ description = "Google Authentication Library" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7"}, {file = "google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64"}, @@ -1117,6 +1127,60 @@ rsa = ["rsa (>=3.1.4,<5)"] testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] urllib3 = ["packaging", "urllib3"] +[[package]] +name = "google-cloud-artifact-registry" +version = "1.21.0" +description = "Google Cloud Artifact Registry API client library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "google_cloud_artifact_registry-1.21.0-py3-none-any.whl", hash = "sha256:a07079035438fd0f2e7264d4318b388650495f011db575405c18c9881449025c"}, + {file = "google_cloud_artifact_registry-1.21.0.tar.gz", hash = "sha256:546e51eb5d463a6e5c668be6727d14f8ec82bc798031398006b2213d703e184c"}, +] + +[package.dependencies] +google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, + {version = ">=1.33.2,<2.0.0", markers = "python_version < \"3.14\""}, +] +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, +] +protobuf = ">=4.25.8,<8.0.0" + +[[package]] +name = "google-cloud-build" +version = "3.36.0" +description = "Google Cloud Build API client library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "google_cloud_build-3.36.0-py3-none-any.whl", hash = "sha256:4a350eb90c384a8ce08649a3384c3813bf4708590e02353f953cc57b1aa2e15f"}, + {file = "google_cloud_build-3.36.0.tar.gz", hash = "sha256:f70de0882ffd0f48ff39b7fcb8b543c2eaeda06c2f067b8aa61125b9427d3ffc"}, +] + +[package.dependencies] +google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, + {version = ">=1.33.2,<2.0.0"}, +] +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, +] +protobuf = ">=4.25.8,<8.0.0" + [[package]] name = "google-cloud-core" version = "2.5.1" @@ -1124,7 +1188,7 @@ description = "Google Cloud API client core library" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7"}, {file = "google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811"}, @@ -1137,6 +1201,59 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" [package.extras] grpc = ["grpcio (>=1.38.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.38.0,<2.0.0)"] +[[package]] +name = "google-cloud-run" +version = "0.16.0" +description = "Google Cloud Run API client library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "google_cloud_run-0.16.0-py3-none-any.whl", hash = "sha256:d7d2dd7307130fde2a0ce27e96d580dd23b7b2d973b6484b94d902e6b2618860"}, + {file = "google_cloud_run-0.16.0.tar.gz", hash = "sha256:d52cf4e6ad3702ae48caccf6abcab543afee6f61c2a6ec753cc62a31e5b629f1"}, +] + +[package.dependencies] +google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, + {version = ">=1.33.2,<2.0.0"}, +] +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, +] +protobuf = ">=4.25.8,<8.0.0" + +[[package]] +name = "google-cloud-service-usage" +version = "1.16.0" +description = "Google Cloud Service Usage API client library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "google_cloud_service_usage-1.16.0-py3-none-any.whl", hash = "sha256:592ba0f799801e8bb1e9f77c451cd3bd8f4e875336951eb5ca4944189f241ec2"}, + {file = "google_cloud_service_usage-1.16.0.tar.gz", hash = "sha256:2c4d16f2d1cdc029c65350ebaa81791da010cd59960204f37fb72c713b3f9658"}, +] + +[package.dependencies] +google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, + {version = ">=1.33.2,<2.0.0"}, +] +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, +] +protobuf = ">=4.25.8,<8.0.0" + [[package]] name = "google-cloud-storage" version = "3.9.0" @@ -1144,7 +1261,7 @@ description = "Google Cloud Storage API client library" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "python_version < \"3.10\" and extra == \"gcp\"" +markers = "python_version < \"3.10\" and (extra == \"gcp\" or extra == \"deploy-gcp\")" files = [ {file = "google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066"}, {file = "google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc"}, @@ -1171,7 +1288,7 @@ description = "Google Cloud Storage API client library" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\" and extra == \"gcp\"" +markers = "python_version >= \"3.10\" and (extra == \"gcp\" or extra == \"deploy-gcp\")" files = [ {file = "google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f"}, {file = "google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286"}, @@ -1198,7 +1315,7 @@ description = "A python wrapper of the C library 'Google CRC32C'" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff"}, {file = "google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288"}, @@ -1242,7 +1359,7 @@ description = "Utilities for Google Media Downloads and Resumable Uploads" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220"}, {file = "google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70"}, @@ -1262,13 +1379,14 @@ description = "Common protobufs used in Google APIs" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "python_version >= \"3.11\" and (extra == \"deploy-gcp\" or extra == \"gcp\") or extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5"}, {file = "googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1"}, ] [package.dependencies] +grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} protobuf = ">=4.25.8,<8.0.0" [package.extras] @@ -1305,6 +1423,120 @@ files = [ griffe = ">=0.49" typing-extensions = ">=4.7" +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.4" +description = "IAM API client library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964"}, + {file = "grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038"}, +] + +[package.dependencies] +googleapis-common-protos = {version = ">=1.63.2,<2.0.0", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0" +protobuf = ">=4.25.8,<8.0.0" + +[[package]] +name = "grpcio" +version = "1.80.0" +description = "HTTP/2-based RPC framework" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c"}, + {file = "grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388"}, + {file = "grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02"}, + {file = "grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc"}, + {file = "grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a"}, + {file = "grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9"}, + {file = "grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199"}, + {file = "grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81"}, + {file = "grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069"}, + {file = "grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58"}, + {file = "grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a"}, + {file = "grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060"}, + {file = "grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2"}, + {file = "grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21"}, + {file = "grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab"}, + {file = "grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1"}, + {file = "grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106"}, + {file = "grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6"}, + {file = "grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440"}, + {file = "grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9"}, + {file = "grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0"}, + {file = "grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2"}, + {file = "grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de"}, + {file = "grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921"}, + {file = "grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411"}, + {file = "grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd"}, + {file = "grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f"}, + {file = "grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f"}, + {file = "grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193"}, + {file = "grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff"}, + {file = "grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad"}, + {file = "grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0"}, + {file = "grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f"}, + {file = "grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6"}, + {file = "grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140"}, + {file = "grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d"}, + {file = "grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7"}, + {file = "grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7"}, + {file = "grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294"}, + {file = "grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50"}, + {file = "grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e"}, + {file = "grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f"}, + {file = "grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9"}, + {file = "grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14"}, + {file = "grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05"}, + {file = "grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1"}, + {file = "grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f"}, + {file = "grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e"}, + {file = "grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae"}, + {file = "grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f"}, + {file = "grpcio-1.80.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4"}, + {file = "grpcio-1.80.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc"}, + {file = "grpcio-1.80.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f"}, + {file = "grpcio-1.80.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957"}, + {file = "grpcio-1.80.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4"}, + {file = "grpcio-1.80.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd"}, + {file = "grpcio-1.80.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4"}, + {file = "grpcio-1.80.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614"}, + {file = "grpcio-1.80.0-cp39-cp39-win32.whl", hash = "sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141"}, + {file = "grpcio-1.80.0-cp39-cp39-win_amd64.whl", hash = "sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de"}, + {file = "grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.80.0)"] + +[[package]] +name = "grpcio-status" +version = "1.80.0" +description = "Status proto mapping for gRPC" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-gcp\"" +files = [ + {file = "grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe"}, + {file = "grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.80.0" +protobuf = ">=6.31.1,<7.0.0" + [[package]] name = "idna" version = "3.11" @@ -1324,14 +1556,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 name = "importlib-metadata" version = "8.7.1" description = "Read metadata from Python packages" -optional = false +optional = true python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" +groups = ["main", "dev", "docs"] files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] +markers = {main = "extra == \"otel\"", dev = "python_full_version < \"3.10.2\"", docs = "python_version < \"3.10\""} [package.dependencies] zipp = ">=3.20" @@ -1345,31 +1577,6 @@ perf = ["ipython"] test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\" and python_full_version < \"3.10.2\"" -files = [ - {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, - {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - [[package]] name = "importlib-resources" version = "6.5.2" @@ -2262,6 +2469,58 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +description = "OpenTelemetry Python API" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"}, + {file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"}, +] + +[package.dependencies] +importlib-metadata = ">=6.0,<8.8.0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.0" +description = "OpenTelemetry Python SDK" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"}, + {file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"}, +] + +[package.dependencies] +opentelemetry-api = "1.40.0" +opentelemetry-semantic-conventions = "0.61b0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +description = "OpenTelemetry Semantic Conventions" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"}, + {file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"}, +] + +[package.dependencies] +opentelemetry-api = "1.40.0" +typing-extensions = ">=4.5.0" + [[package]] name = "packaging" version = "26.0" @@ -2392,7 +2651,7 @@ description = "Beautiful, Pythonic protocol buffers" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718"}, {file = "proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24"}, @@ -2411,7 +2670,7 @@ description = "" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.10\" and extra == \"gcp\"" +markers = "python_version >= \"3.11\" and (extra == \"deploy-gcp\" or extra == \"gcp\") or extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3"}, {file = "protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326"}, @@ -2425,25 +2684,6 @@ files = [ {file = "protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135"}, ] -[[package]] -name = "protobuf" -version = "7.34.1" -description = "" -optional = true -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version >= \"3.10\" and extra == \"gcp\"" -files = [ - {file = "protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7"}, - {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b"}, - {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a"}, - {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4"}, - {file = "protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a"}, - {file = "protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c"}, - {file = "protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11"}, - {file = "protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280"}, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -2451,7 +2691,7 @@ description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, @@ -2464,7 +2704,7 @@ description = "A collection of ASN.1-based protocols modules" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "extra == \"gcp\"" +markers = "extra == \"gcp\" or extra == \"deploy-gcp\"" files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, @@ -2496,7 +2736,7 @@ files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -markers = {main = "extra == \"gcp\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\" and python_version < \"3.10\"", dev = "(platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and python_version < \"3.10\" and implementation_name != \"PyPy\""} +markers = {main = "(extra == \"gcp\" or extra == \"deploy-gcp\" or extra == \"deploy-github\") and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\" and python_version < \"3.10\"", dev = "(platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and python_version < \"3.10\" and implementation_name != \"PyPy\""} [[package]] name = "pycparser" @@ -2509,7 +2749,7 @@ files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] -markers = {main = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and extra == \"gcp\" and implementation_name != \"PyPy\"", dev = "python_version >= \"3.10\" and (platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and implementation_name != \"PyPy\""} +markers = {main = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\" and (extra == \"gcp\" or extra == \"deploy-gcp\" or extra == \"deploy-github\") and implementation_name != \"PyPy\"", dev = "python_version >= \"3.10\" and (platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and implementation_name != \"PyPy\""} [[package]] name = "pydantic" @@ -2679,6 +2919,26 @@ files = [ {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, ] +[[package]] +name = "pygithub" +version = "2.9.0" +description = "Use the full Github API v3" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-github\"" +files = [ + {file = "pygithub-2.9.0-py3-none-any.whl", hash = "sha256:5e2b260ce327bffce9b00f447b65953ef7078ffe93e5a5425624a3075483927c"}, + {file = "pygithub-2.9.0.tar.gz", hash = "sha256:a26abda1222febba31238682634cad11d8b966137ed6cc3c5e445b29a11cb0a4"}, +] + +[package.dependencies] +pyjwt = {version = ">=2.4.0", extras = ["crypto"]} +pynacl = ">=1.4.0" +requests = ">=2.14.0" +typing-extensions = ">=4.5.0" +urllib3 = ">=1.26.0" + [[package]] name = "pygments" version = "2.20.0" @@ -2694,6 +2954,29 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.12.1" +description = "JSON Web Token implementation in Python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"deploy-github\"" +files = [ + {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, + {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] + [[package]] name = "pylint" version = "3.3.9" @@ -2711,8 +2994,8 @@ astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.6", markers = "python_version == \"3.11\""}, + {version = ">=0.2", markers = "python_version < \"3.11\""}, ] isort = ">=4.2.5,<5.13 || >5.13,<7" mccabe = ">=0.6,<0.8" @@ -2744,6 +3027,49 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.19.1)"] +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"deploy-github\"" +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + [[package]] name = "pyparsing" version = "3.3.2" @@ -3614,14 +3940,14 @@ test = ["pytest", "pytest-cov"] name = "zipp" version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false +optional = true python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] -markers = {dev = "python_full_version < \"3.10.2\"", docs = "python_version < \"3.10\""} +markers = {main = "extra == \"otel\"", dev = "python_full_version < \"3.10.2\"", docs = "python_version < \"3.10\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] @@ -3633,10 +3959,14 @@ type = ["pytest-mypy"] [extras] aws = ["boto3"] +deploy-aws = ["boto3"] +deploy-gcp = ["google-auth", "google-cloud-artifact-registry", "google-cloud-build", "google-cloud-run", "google-cloud-service-usage", "google-cloud-storage"] +deploy-github = ["PyGithub"] gcp = ["google-cloud-storage"] +otel = ["opentelemetry-api", "opentelemetry-sdk"] scheduler = ["croniter"] [metadata] lock-version = "2.1" python-versions = ">=3.9.0" -content-hash = "8bc802a04084146429175caab4cf9431867a4253da0c0d117727a3a969164ce0" +content-hash = "87f99b43e3f745344716d16147410d299834c2a6c1ed4b8fbb8a27ce6d60953c" diff --git a/pyproject.toml b/pyproject.toml index ee0c2cc0..f56d7c60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dotflow" -version = "0.14.1.dev2" +version = "0.15.0.dev2" authors = [ { name="Fernando Celmer", email="email@fernandocelmer.com" }, ] @@ -56,13 +56,16 @@ Changelog = "https://dotflow-io.github.io/dotflow/nav/development/release-notes/ [project.optional-dependencies] aws = ["boto3"] gcp = ["google-cloud-storage"] +deploy-aws = ["boto3"] +deploy-gcp = ["google-cloud-run", "google-cloud-build", "google-cloud-artifact-registry", "google-cloud-service-usage", "google-cloud-storage", "google-auth"] +deploy-github = ["PyGithub", "GitPython"] scheduler = ["croniter"] otel = ["opentelemetry-api", "opentelemetry-sdk"] sentry = ["sentry-sdk"] [tool.poetry] name = "dotflow" -version = "0.14.1.dev2" +version = "0.15.0.dev2" description = "🎲 Dotflow turns an idea into flow! Lightweight Python library for execution pipelines with retry, parallel, cron and async support." authors = ["Fernando Celmer "] readme = "README.md" diff --git a/tests/cloud/__init__.py b/tests/cloud/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cloud/aws/__init__.py b/tests/cloud/aws/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cloud/aws/services/__init__.py b/tests/cloud/aws/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cloud/aws/services/test_s3.py b/tests/cloud/aws/services/test_s3.py new file mode 100644 index 00000000..0bf21697 --- /dev/null +++ b/tests/cloud/aws/services/test_s3.py @@ -0,0 +1,38 @@ +"""Test S3 service.""" + +import unittest + +import boto3 +from moto import mock_aws + +from dotflow.cloud.aws.services.s3 import S3 +from dotflow.cloud.core import ObjectStorage + +BUCKET = "test-bucket" +PREFIX = "dotflow/" +REGION = "us-east-1" + + +class TestS3(unittest.TestCase): + def setUp(self): + self.mock = mock_aws() + self.mock.start() + boto3.client("s3", region_name=REGION).create_bucket(Bucket=BUCKET) + + def tearDown(self): + self.mock.stop() + + def test_instance(self): + s3 = S3(bucket=BUCKET, prefix=PREFIX, region=REGION) + self.assertIsInstance(s3, ObjectStorage) + + def test_write_and_read(self): + s3 = S3(bucket=BUCKET, prefix=PREFIX, region=REGION) + s3.write("key1", [{"a": 1}]) + result = s3.read("key1") + self.assertEqual(result, [{"a": 1}]) + + def test_read_nonexistent(self): + s3 = S3(bucket=BUCKET, prefix=PREFIX, region=REGION) + result = s3.read("missing") + self.assertEqual(result, []) diff --git a/tests/cloud/aws/test_ecs_deployer.py b/tests/cloud/aws/test_ecs_deployer.py new file mode 100644 index 00000000..ca066bf5 --- /dev/null +++ b/tests/cloud/aws/test_ecs_deployer.py @@ -0,0 +1,73 @@ +"""Test ECSDeployer.""" + +import unittest +from unittest.mock import MagicMock, patch + +from dotflow.cloud.aws.ecs_deployer import ECSDeployer +from dotflow.cloud.core import Deployer + + +class TestECSDeployer(unittest.TestCase): + def _make_deployer(self): + with patch.object(ECSDeployer, "__init__", return_value=None): + deployer = ECSDeployer() + deployer._region = "us-east-1" + deployer._account_id = "123456789012" + deployer._ecs = MagicMock() + deployer._ecr = MagicMock() + deployer._iam = MagicMock() + deployer._logs = MagicMock() + return deployer + + def test_instance(self): + deployer = self._make_deployer() + self.assertIsInstance(deployer, Deployer) + + def test_setup_creates_role_logs_cluster(self): + deployer = self._make_deployer() + deployer._ecs.describe_clusters.return_value = {"clusters": []} + + deployer.setup("test") + + deployer._iam.ensure_ecs_execution_role.assert_called_once() + deployer._logs.ensure_log_group.assert_called_once_with("/ecs/test") + deployer._ecs.create_cluster.assert_called_once() + + def test_deploy_with_task_definition_file(self): + deployer = self._make_deployer() + deployer._ecs.describe_clusters.return_value = {"clusters": []} + + with patch("dotflow.cloud.aws.ecs_deployer.Path") as mock_path: + mock_path.cwd.return_value.__truediv__ = MagicMock( + return_value=MagicMock( + exists=MagicMock(return_value=True), + read_text=MagicMock(return_value='{"family": "test"}'), + ) + ) + deployer.deploy("test") + + deployer._ecr.push.assert_called_once_with("test") + deployer._ecs.register_task_definition.assert_called_once() + + def test_deploy_creates_task_definition(self): + deployer = self._make_deployer() + deployer._ecs.describe_clusters.return_value = {"clusters": []} + deployer._iam.ensure_ecs_execution_role.return_value = "arn:role" + + with patch("dotflow.cloud.aws.ecs_deployer.Path") as mock_path: + mock_path.cwd.return_value.__truediv__ = MagicMock( + return_value=MagicMock(exists=MagicMock(return_value=False)) + ) + deployer.deploy("test") + + deployer._ecs.register_task_definition.assert_called_once() + + def test_ensure_cluster_skips_if_active(self): + deployer = self._make_deployer() + deployer._ecs.describe_clusters.return_value = { + "clusters": [{"status": "ACTIVE"}] + } + + deployer._ensure_cluster("test") + + deployer._ecs.create_cluster.assert_not_called() diff --git a/tests/cloud/aws/test_lambda_api_deployer.py b/tests/cloud/aws/test_lambda_api_deployer.py new file mode 100644 index 00000000..a885ca41 --- /dev/null +++ b/tests/cloud/aws/test_lambda_api_deployer.py @@ -0,0 +1,64 @@ +"""Test LambdaApiDeployer.""" + +import unittest +from unittest.mock import MagicMock, patch + +from dotflow.cloud.aws.lambda_api_deployer import LambdaApiDeployer +from dotflow.cloud.core import Deployer + + +class _ResourceConflict(Exception): + pass + + +class TestLambdaApiDeployer(unittest.TestCase): + def _make_deployer(self): + with patch.object(LambdaApiDeployer, "__init__", return_value=None): + deployer = LambdaApiDeployer() + deployer._region = "us-east-1" + deployer._account_id = "123456789012" + deployer._name = "test" + deployer._boto3 = MagicMock() + deployer._lambda = MagicMock() + deployer._lambda.exceptions.ResourceConflictException = ( + _ResourceConflict + ) + deployer._ecr = MagicMock() + deployer._iam = MagicMock() + deployer._logs = MagicMock() + return deployer + + def test_instance(self): + deployer = self._make_deployer() + self.assertIsInstance(deployer, Deployer) + + def test_deploy_creates_api(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + mock_apigw = MagicMock() + mock_apigw.get_apis.return_value = {"Items": []} + mock_apigw.create_api.return_value = {"ApiId": "abc123"} + mock_apigw.create_integration.return_value = {"IntegrationId": "int1"} + deployer._boto3.client.return_value = mock_apigw + + deployer.deploy("test") + + mock_apigw.create_api.assert_called_once() + mock_apigw.create_route.assert_called_once() + + def test_deploy_skips_existing_api(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + mock_apigw = MagicMock() + mock_apigw.get_apis.return_value = { + "Items": [{"Name": "test-api", "ApiId": "existing"}] + } + deployer._boto3.client.return_value = mock_apigw + + deployer.deploy("test") + + mock_apigw.create_api.assert_not_called() diff --git a/tests/cloud/aws/test_lambda_deployer.py b/tests/cloud/aws/test_lambda_deployer.py new file mode 100644 index 00000000..92b444b6 --- /dev/null +++ b/tests/cloud/aws/test_lambda_deployer.py @@ -0,0 +1,79 @@ +"""Test LambdaDeployer.""" + +import unittest +from unittest.mock import MagicMock, patch + +from dotflow.cloud.aws.lambda_deployer import LambdaDeployer +from dotflow.cloud.core import Deployer + + +class _ResourceNotFound(Exception): + pass + + +class _ResourceConflict(Exception): + pass + + +class TestLambdaDeployer(unittest.TestCase): + def _make_deployer(self): + with patch.object(LambdaDeployer, "__init__", return_value=None): + deployer = LambdaDeployer() + deployer._region = "us-east-1" + deployer._account_id = "123456789012" + deployer._name = "test" + deployer._boto3 = MagicMock() + deployer._lambda = MagicMock() + deployer._lambda.exceptions.ResourceNotFoundException = ( + _ResourceNotFound + ) + deployer._lambda.exceptions.ResourceConflictException = ( + _ResourceConflict + ) + deployer._ecr = MagicMock() + deployer._iam = MagicMock() + deployer._logs = MagicMock() + return deployer + + def test_instance(self): + deployer = self._make_deployer() + self.assertIsInstance(deployer, Deployer) + + def test_deploy_creates_function(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + deployer._lambda.get_function.side_effect = _ResourceNotFound() + + deployer.deploy("test") + + deployer._lambda.create_function.assert_called_once() + + def test_deploy_updates_existing(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + deployer.deploy("test") + + deployer._lambda.update_function_code.assert_called_once() + + def test_deploy_with_schedule(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + mock_events = MagicMock() + deployer._boto3.client.return_value = mock_events + + deployer.deploy("test", schedule="rate(6 hours)") + + mock_events.put_rule.assert_called_once() + + def test_deploy_without_schedule(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + deployer.deploy("test") + + deployer._boto3.client.assert_not_called() diff --git a/tests/cloud/aws/test_lambda_s3_deployer.py b/tests/cloud/aws/test_lambda_s3_deployer.py new file mode 100644 index 00000000..25b4d567 --- /dev/null +++ b/tests/cloud/aws/test_lambda_s3_deployer.py @@ -0,0 +1,51 @@ +"""Test LambdaS3Deployer.""" + +import unittest +from unittest.mock import MagicMock, patch + +from dotflow.cloud.aws.lambda_s3_deployer import LambdaS3Deployer +from dotflow.cloud.core import Deployer + + +class _ResourceConflict(Exception): + pass + + +class _BucketExists(Exception): + pass + + +class TestLambdaS3Deployer(unittest.TestCase): + def _make_deployer(self): + with patch.object(LambdaS3Deployer, "__init__", return_value=None): + deployer = LambdaS3Deployer() + deployer._region = "us-east-1" + deployer._account_id = "123456789012" + deployer._name = "test" + deployer._boto3 = MagicMock() + deployer._lambda = MagicMock() + deployer._lambda.exceptions.ResourceConflictException = ( + _ResourceConflict + ) + deployer._ecr = MagicMock() + deployer._iam = MagicMock() + deployer._logs = MagicMock() + return deployer + + def test_instance(self): + deployer = self._make_deployer() + self.assertIsInstance(deployer, Deployer) + + def test_deploy_creates_bucket_and_notification(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + mock_s3 = MagicMock() + mock_s3.exceptions.BucketAlreadyOwnedByYou = _BucketExists + deployer._boto3.client.return_value = mock_s3 + + deployer.deploy("test") + + mock_s3.create_bucket.assert_called_once() + mock_s3.put_bucket_notification_configuration.assert_called_once() diff --git a/tests/cloud/aws/test_lambda_sqs_deployer.py b/tests/cloud/aws/test_lambda_sqs_deployer.py new file mode 100644 index 00000000..649c85e5 --- /dev/null +++ b/tests/cloud/aws/test_lambda_sqs_deployer.py @@ -0,0 +1,73 @@ +"""Test LambdaSQSDeployer.""" + +import unittest +from unittest.mock import MagicMock, patch + +from dotflow.cloud.aws.lambda_sqs_deployer import LambdaSQSDeployer +from dotflow.cloud.core import Deployer + + +class _QueueExists(Exception): + pass + + +class TestLambdaSQSDeployer(unittest.TestCase): + def _make_deployer(self): + with patch.object(LambdaSQSDeployer, "__init__", return_value=None): + deployer = LambdaSQSDeployer() + deployer._region = "us-east-1" + deployer._account_id = "123456789012" + deployer._name = "test" + deployer._boto3 = MagicMock() + deployer._lambda = MagicMock() + deployer._ecr = MagicMock() + deployer._iam = MagicMock() + deployer._logs = MagicMock() + return deployer + + def test_instance(self): + deployer = self._make_deployer() + self.assertIsInstance(deployer, Deployer) + + def test_deploy_creates_queue_and_mapping(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + mock_sqs = MagicMock() + mock_sqs.create_queue.return_value = { + "QueueUrl": "https://sqs/test-queue" + } + mock_sqs.get_queue_attributes.return_value = { + "Attributes": {"QueueArn": "arn:aws:sqs:us-east-1:123:test-queue"} + } + deployer._boto3.client.return_value = mock_sqs + deployer._lambda.list_event_source_mappings.return_value = { + "EventSourceMappings": [] + } + + deployer.deploy("test") + + mock_sqs.create_queue.assert_called_once() + deployer._lambda.create_event_source_mapping.assert_called_once() + + def test_deploy_skips_existing_mapping(self): + deployer = self._make_deployer() + deployer._ecr.push.return_value = "uri" + deployer._iam.ensure_lambda_role.return_value = "arn" + + mock_sqs = MagicMock() + mock_sqs.create_queue.return_value = { + "QueueUrl": "https://sqs/test-queue" + } + mock_sqs.get_queue_attributes.return_value = { + "Attributes": {"QueueArn": "arn:aws:sqs:us-east-1:123:test-queue"} + } + deployer._boto3.client.return_value = mock_sqs + deployer._lambda.list_event_source_mappings.return_value = { + "EventSourceMappings": [{"UUID": "existing"}] + } + + deployer.deploy("test") + + deployer._lambda.create_event_source_mapping.assert_not_called() diff --git a/tests/cloud/gcp/__init__.py b/tests/cloud/gcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cloud/gcp/services/__init__.py b/tests/cloud/gcp/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cloud/gcp/services/test_gcs.py b/tests/cloud/gcp/services/test_gcs.py new file mode 100644 index 00000000..9252578f --- /dev/null +++ b/tests/cloud/gcp/services/test_gcs.py @@ -0,0 +1,56 @@ +"""Test GCS service.""" + +import sys +import unittest +from json import dumps +from unittest.mock import MagicMock, patch + +from dotflow.cloud.core import ObjectStorage + +mock_gcloud = MagicMock() +sys.modules["google"] = MagicMock() +sys.modules["google.cloud"] = MagicMock() +sys.modules["google.cloud.storage"] = mock_gcloud +sys.modules["google.api_core"] = MagicMock() +sys.modules["google.api_core.exceptions"] = MagicMock() + +from dotflow.cloud.gcp.services.gcs import GCS # noqa: E402, I001 + + +class TestGCS(unittest.TestCase): + def setUp(self): + with patch.object(GCS, "__init__", return_value=None): + self.gcs = GCS() + self.mock_bucket = MagicMock() + self.gcs._bucket = self.mock_bucket + self.gcs._not_found = Exception + self.gcs.prefix = "dotflow/" + + def test_instance(self): + self.assertIsInstance(self.gcs, ObjectStorage) + + def test_write(self): + blob = MagicMock() + self.mock_bucket.blob.return_value = blob + + self.gcs.write("key1", [{"a": 1}]) + + blob.upload_from_string.assert_called_once() + + def test_read(self): + blob = MagicMock() + blob.download_as_text.return_value = dumps([{"a": 1}]) + self.mock_bucket.blob.return_value = blob + + result = self.gcs.read("key1") + + self.assertEqual(result, [{"a": 1}]) + + def test_read_nonexistent(self): + blob = MagicMock() + blob.download_as_text.side_effect = Exception("not found") + self.mock_bucket.blob.return_value = blob + + result = self.gcs.read("missing") + + self.assertEqual(result, []) diff --git a/tests/cloud/github/__init__.py b/tests/cloud/github/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cloud/github/test_actions_deployer.py b/tests/cloud/github/test_actions_deployer.py new file mode 100644 index 00000000..21de3284 --- /dev/null +++ b/tests/cloud/github/test_actions_deployer.py @@ -0,0 +1,79 @@ +"""Test ActionsDeployer.""" + +import unittest +from unittest.mock import MagicMock, patch + +from dotflow.cloud.core import Deployer +from dotflow.cloud.github.actions_deployer import ActionsDeployer + + +class _GithubException(Exception): + pass + + +class TestActionsDeployer(unittest.TestCase): + def _make_deployer(self): + with patch.object(ActionsDeployer, "__init__", return_value=None): + deployer = ActionsDeployer() + deployer._github = MagicMock() + deployer._GithubException = _GithubException + deployer._user = MagicMock() + deployer._token = "fake-token" + return deployer + + def test_instance(self): + deployer = self._make_deployer() + self.assertIsInstance(deployer, Deployer) + + def test_create_new_repo(self): + deployer = self._make_deployer() + deployer._user.get_repo.side_effect = _GithubException() + mock_repo = MagicMock() + deployer._user.create_repo.return_value = mock_repo + + repo = deployer._create_or_get_repo("test") + + deployer._user.create_repo.assert_called_once() + self.assertEqual(repo, mock_repo) + + def test_use_existing_repo(self): + deployer = self._make_deployer() + mock_repo = MagicMock() + deployer._user.get_repo.return_value = mock_repo + + repo = deployer._create_or_get_repo("test") + + deployer._user.create_repo.assert_not_called() + self.assertEqual(repo, mock_repo) + + def test_create_file(self): + deployer = self._make_deployer() + mock_repo = MagicMock() + mock_repo.get_contents.side_effect = _GithubException() + + deployer._create_or_update_file(mock_repo, "test.py", "print('hi')") + + mock_repo.create_file.assert_called_once() + + def test_update_existing_file(self): + deployer = self._make_deployer() + mock_repo = MagicMock() + mock_existing = MagicMock() + mock_existing.sha = "abc123" + mock_repo.get_contents.return_value = mock_existing + + deployer._create_or_update_file(mock_repo, "test.py", "print('hi')") + + mock_repo.update_file.assert_called_once() + + def test_deploy_creates_repo_and_pushes(self): + deployer = self._make_deployer() + mock_repo = MagicMock() + mock_repo.html_url = "https://github.com/user/test" + deployer._user.get_repo.return_value = mock_repo + mock_repo.get_contents.side_effect = _GithubException() + + with patch.object(deployer, "_push_files") as mock_push: + deployer.deploy("test") + + mock_push.assert_called_once_with(mock_repo) diff --git a/tests/providers/test_storage_gcs.py b/tests/providers/test_storage_gcs.py index 1cb899db..f273b0dc 100644 --- a/tests/providers/test_storage_gcs.py +++ b/tests/providers/test_storage_gcs.py @@ -30,13 +30,34 @@ def setUp(self): from dotflow.providers.storage_gcs import StorageGCS self.storage = StorageGCS() - self.storage.client = self.mock_client - self.storage.bucket_obj = self.mock_bucket - self.storage.prefix = PREFIX - self.storage._not_found = Exception + + mock_gcs = MagicMock() + mock_gcs._bucket = self.mock_bucket + mock_gcs._not_found = Exception + mock_gcs.prefix = PREFIX + mock_gcs.read = self._mock_read + mock_gcs.write = self._mock_write + self.storage._gcs = mock_gcs + self._written = {} + + def _mock_read(self, key): + blob = self.mock_bucket.blob(f"{PREFIX}{key}") + try: + data = blob.download_as_text() + from json import loads + + return loads(data) + except Exception: + return [] + + def _mock_write(self, key, data): + blob = self.mock_bucket.blob(f"{PREFIX}{key}") + from json import dumps + + blob.upload_from_string(dumps(data), content_type="application/json") def test_storage_gcs_instance(self): - self.assertEqual(self.storage.prefix, PREFIX) + self.assertEqual(self.storage._gcs.prefix, PREFIX) def test_post(self): expected_value = {"foo": "bar"} diff --git a/tests/providers/test_storage_s3.py b/tests/providers/test_storage_s3.py index e29c5e4e..a11eb6e6 100644 --- a/tests/providers/test_storage_s3.py +++ b/tests/providers/test_storage_s3.py @@ -30,8 +30,8 @@ def tearDown(self): def test_storage_s3_instance(self): storage = StorageS3(bucket=BUCKET, prefix=PREFIX, region=REGION) - self.assertEqual(storage.bucket, BUCKET) - self.assertEqual(storage.prefix, PREFIX) + self.assertEqual(storage._s3.bucket, BUCKET) + self.assertEqual(storage._s3.prefix, PREFIX) def test_post(self): expected_value = {"foo": "bar"}