This release is a pre-release and may not be stable for production use.
dwe-core
The DWE CLI (dwe) is the orchestration brain of the Data Warehouse Ecosystem. It takes a blank or existing client Git repository and injects a fully working Adapter — infrastructure, application config, CI/CD pipelines, and local dev commands — in a single command.
How it works
dwe create-service test_adapter --git-repo https://github.com/client/repo --envs dev --envs prod
Internally this does:
1. Clone GitPython clones the client repo to a temp directory
2. Hydrate Copier renders the adapter template into the clone
3. State CLI writes dwe-state.json
4. CI/CD CLI renders per-environment GitHub Actions / GitLab CI files
5. Branch initial-commit branch is created and committed
6. Env branches dev, prod branches are created from initial-commit
7. Push All branches are pushed to the remote
8. Secrets GitHub/GitLab API uploads secrets to the repository settings
The result is a client repo that already has working infrastructure code, a justfile with just up / just deploy-prod, and CI/CD that deploys to the right environment when you push to its branch.
Installation
pip install poetry # if not already installed
poetry install # from dwe-core source (creates venv, installs deps)
# or once published:
pip install dwe-core
Verify:
dwe --help
dwe list-adapters
Commands
dwe create-service
dwe create-service <adapter_name> \
--git-repo <url> \
[--envs <name>]... \ # repeat for multiple environments; default: development, main
[--secrets <json>] \ # e.g. '{"AWS_KEY":"abc"}'
[--tag <version>] \ # adapter git tag, e.g. v1.2.0
[--token <api-token>] \ # or set GITHUB_TOKEN / GITLAB_TOKEN
[--set key=value]... \ # override copier questions (run `dwe adapter-questions` to see keys)
[--clone-dir <path>] # default: temp dir
Example — full run:
export GITHUB_TOKEN=ghp_xxxx
dwe create-service dwe_cube \
--git-repo https://github.com/acme/cube-deploy \
--envs dev \
--envs prod \
--secrets '{"AZURE_CLIENT_ID":"...","AZURE_CLIENT_SECRET":"...","PULUMI_AZURE_STATE":"azblob://deploy-state/cube-state"}' \
--tag v1.0.0 \
--set git_repo_url=https://github.com/acme/cube-deploy \
--set cloud_provider=azure
After this runs, the cube-deploy repo has:
.github/workflows/
deploy-dev.yaml
deploy-prod.yaml
docker-compose.yml
.env.example
justfile
pulumi/
__main__.py <- project_name already substituted
Pulumi.yaml
requirements.txt
dwe-hydration.yaml
.copier-answers.yml <- Copier's internal state (enables future updates)
dwe update-service
dwe update-service <adapter_name> <local_path> [--tag <version>]
Example:
dwe update-service test_adapter ./data-platform --tag v1.2.0
Internally:
- Reads
dwe-state.jsonand validates the adapter name matches - Creates a branch
dwe-update-20260322-1.2.0 - Runs
copier.run_update()— smart merge that preserves your customisations - Updates
dwe-state.jsonwith the new version
Review the diff on the branch, then merge into your environment branches to trigger deployments.
dwe local-development
dwe local-development <adapter_name> <secret> \
[--output-dir <path>] \ # default: current directory
[--adapter-path <path>] \ # override auto-detected template path
[--aws-region <region>] # default: us-east-1
Creates a local development folder from an adapter template, seeded with runtime config pulled directly from AWS Secrets Manager. No git repository is initialised — the output is a plain directory ready for docker compose up.
If the folder already exists it is wiped and recreated from scratch — running the command again is a clean reset.
Example:
dwe local-development dwe_cube my-cube-secret --aws-region us-east-1
What happens internally:
1. Locate Finds the local Copier template for the adapter
(auto-detected from monorepo structure, or --adapter-path)
2. Reset Deletes <output-dir>/<adapter_name>/ if it already exists
3. Fetch Calls AWS Secrets Manager to retrieve <secret> as JSON
4. Hydrate Runs Copier to render the adapter template into the new folder
5. .env Writes all secret key=value pairs to .env in the output folder
After the command completes:
dwe_cube/
├── docker-compose.yml
├── justfile
├── .env ← populated from AWS Secrets Manager
└── ... ← everything else from the adapter template
Start the stack:
cd dwe_cube
docker compose up
Adapter path resolution (in order):
--adapter-pathflag (explicit override)pathfield inadapters.json(if set)- Auto-detect: sibling directory of
dwe-core/in the monorepo root named<adapter_name>
AWS credentials — the command uses boto3 and picks up credentials the standard way: environment variables (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), ~/.aws/credentials, an IAM role, etc.
dwe adapter-questions
dwe adapter-questions <adapter_name>
Lists the copier questions an adapter accepts. Use these as --set key=value overrides in create-service.
dwe show-properties
dwe show-properties <adapter_name>
Shows supported cloud providers, git providers, services, and CI templates for an adapter.
dwe show-services
dwe show-services <adapter_name>
Lists the Docker services bundled in an adapter.
dwe show-secrets-template
dwe show-secrets-template <adapter_name> [--cloud aws|azure] [--git-provider github|gitlab]
Prints a JSON template of all secrets the adapter requires. Fill it in and upload with set-secrets.
dwe set-secrets
dwe set-secrets \
--git-repo <url> \
[--secrets <json>] \ # inline JSON
[--secrets-file <path>] \ # or a JSON file
[--adapter <name>] \ # validate required keys before pushing
[--token <api-token>] # or GITHUB_TOKEN / GITLAB_TOKEN
Creates or updates GitHub Actions secrets / GitLab CI variables in a repository.
dwe list-secrets
dwe list-secrets --git-repo <url> [--adapter <name>] [--token <api-token>]
Lists secret key names in a repository. Values are never revealed. Pass --adapter to cross-reference against what the adapter requires.
dwe delete-secret
dwe delete-secret --git-repo <url> --key <KEY> [--token <api-token>]
Deletes a single secret / CI variable from a repository.
dwe list-adapters
dwe list-adapters
Shows all adapters registered in adapters.json.
Adapter Registry (adapters.json)
{
"test_adapter": {
"path": "/absolute/path/to/dwe_test_adapter",
"type": "local",
"description": "Test adapter: AWS EC2 instance via Pulumi"
},
"superset_adapter": {
"url": "https://github.com/hipposys/dwe-superset-adapter",
"type": "git",
"description": "Apache Superset on ECS"
}
}
How to Define a New Adapter
An adapter is a real, runnable project that also serves as a Copier template. The guiding principle:
The adapter must work locally as-is. A developer should be able to
git clonethe adapter, runjust up, and have a working service — without running the DWE CLI at all.
Step 1: Create the adapter repository
mkdir my_adapter && cd my_adapter
git init
Step 2: Build a working application first
Build your service as a real project before adding any template variables. For example, if you're building a Superset adapter:
# Make it work locally first
docker compose up # verify it runs
Only once everything works locally do you introduce {{ variables }}.
Step 3: Directory structure
my_adapter/
├── copier.yml # Copier config + question definitions
│
├── docker-compose.yml # Real, runnable. Uses ${ENV_VAR:-default} for runtime values.
├── docker-compose.prod.yml # Production overrides (restart policy, logging)
├── .env.example # Template for secrets — committed; .env is git-ignored
├── .gitignore
│
├── justfile # Dev commands (just up, just logs, etc.)
│
├── blueprint/ # Application-level config files
│ └── instance-setup.sh # VM user-data bootstrap script
│
├── pulumi/ # Pulumi IaC — only .jinja files are templated
│ ├── __main__.py # Entry point (imports _aws.py / _azure.py)
│ ├── _aws.py # AWS-specific resources (ASG, ALB, Route53…)
│ ├── _azure.py # Azure-specific resources (VMSS, AppGW, NLB…)
│ ├── Pulumi.yaml.jinja # <- .jinja because it embeds {{ project_name }}
│ ├── dwe-hydration.yaml.jinja
│ └── requirements.txt.jinja
│
└── ci-templates/ # Jinja2 templates rendered by the CLI (not Copier)
├── github.yaml # GitHub Actions — two-path deploy
└── gitlab.yaml # GitLab CI — two-path deploy
Step 4: Write copier.yml
copier.yml controls how Copier processes the adapter. Key settings:
_templates_suffix: .jinja # ONLY files ending in .jinja are treated as templates
# Everything else is copied verbatim
_exclude:
- copier.yml # Don't copy Copier's own config
- ci-templates # CLI handles this separately
- README.md # Adapter's README is not for client repos
- .git
- .env # Never copy actual secrets
- __pycache__
- "*.pyc"
_skip_if_exists:
- .env.example # Preserve user customisations on updates
# Questions (answered non-interactively by the dwe CLI):
project_name:
type: str
help: "Client project name (used for cloud resource naming)"
adapter_name:
type: str
default: "my_adapter"
when: false # always set programmatically
adapter_version:
type: str
default: "v1.0.0"
when: false # always set programmatically
environments:
type: yaml
default: "[development, main]"
aws_region:
type: str
default: "us-east-1"
Step 5: Decide what needs Jinja2
Apply this rule: if the value changes per client, use {{ variable }}. If it changes per deployment environment, use a .env variable.
| File | Approach | Reason |
|---|---|---|
docker-compose.yml |
.env interpolation (${VAR:-default}) |
Works locally without any substitution; runtime config |
pulumi/Pulumi.yaml |
Jinja2 (.jinja extension) |
Stack name must be unique per client |
pulumi/dwe-hydration.yaml |
Jinja2 (.jinja extension) |
Adapter metadata stamped per client |
pulumi/requirements.txt |
Jinja2 (.jinja extension) |
Pin adapter version at hydration time |
justfile |
Verbatim copy (no .jinja) |
Commands are identical across clients |
blueprint/instance-setup.sh |
Verbatim copy | Generic bootstrap, no client-specific values |
.env.example |
Verbatim copy | Users fill in real values after cloning |
Jinja2 syntax in .jinja files:
# pulumi/Pulumi.yaml.jinja
name: {{ project_name }} # <- substituted by Copier
runtime: python
After dwe create-service this becomes:
name: acme-cube-deploy
runtime: python
Step 6: Write ci-templates/deploy.yaml
This is a Jinja2 file rendered by the dwe CLI (not by Copier) to generate one workflow file per environment. The CLI uses {@ @} as variable delimiters (not {{ }}), so GitHub Actions ${{ secrets.X }} syntax passes through untouched — no escaping needed.
name: Deploy to {@ ENV_NAME @}
on:
push:
branches:
- {@ ENV_NAME @}
pull_request:
branches:
- {@ ENV_NAME @}
jobs:
deploy:
runs-on: ubuntu-latest
environment: {@ ENV_NAME @}
steps:
- uses: actions/checkout@v4
- name: Deploy
run: just deploy-prod
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} # passes through unchanged
AWS_REGION: {@ AWS_REGION @} # substituted by dwe CLI
Available variables:
| Variable | Description |
|---|---|
{@ ENV_NAME @} |
Environment branch name (e.g. dev, prod) |
{@ WORKSPACE_NAME @} |
Pulumi stack / workspace name |
{@ SECRET_NAME @} |
Secret Manager secret name (Key Vault / Secrets Manager) |
{@ PROJECT_NAME @} |
Pulumi project name (used for config key prefix) |
{@ CLOUD_PROVIDER @} |
aws or azure |
{@ AWS_REGION @} |
AWS region (AWS only) |
Step 7: Register the adapter
Add an entry to dwe-core/adapters.json:
Local (development):
{
"my_adapter": {
"path": "/absolute/path/to/my_adapter",
"type": "local",
"description": "My adapter description"
}
}
Remote Git (production):
{
"my_adapter": {
"url": "https://github.com/your-org/my-adapter",
"type": "git",
"description": "My adapter description"
}
}
Step 8: Test the adapter
Test locally first (without DWE CLI):
cd my_adapter
cp .env.example .env
just up # docker compose up — must work here
Test Copier rendering in isolation:
pip install copier
copier copy /path/to/my_adapter /tmp/test-output \
--data project_name=testproject \
--data aws_region=us-east-1 \
--defaults --overwrite --trust
# Inspect the output
ls /tmp/test-output
cat /tmp/test-output/infrastructure/Pulumi.yaml # should have project_name substituted
cat /tmp/test-output/docker-compose.yml # should be identical to source
cd /tmp/test-output && docker compose up # should still work
Test via dwe CLI:
dwe create-service my_adapter \
--git-repo https://github.com/test-org/empty-repo \
--envs development \
--envs main
Adapter Versioning and Updates
Tag your adapter repository with semantic version tags. The DWE CLI and Copier use these tags for update-service:
cd my_adapter
git add -A && git commit -m "feat: add postgres service"
git tag v1.1.0
git push origin v1.1.0
When a client wants to update:
dwe update-service my_adapter ./client-repo --tag v1.1.0
Copier reads the source URL from .copier-answers.yml in the client repo, checks out v1.1.0, and runs a 3-way merge. Files the user has customised are preserved where possible; conflicts surface as standard git merge conflicts.
What gets updated:
infrastructure/— Pulumi code (Jinja2 re-rendered with new template)blueprint/— Application config filesjustfile— Dev commands
What is NOT updated (protected):
.env.example— skipped if it already exists (_skip_if_existsincopier.yml).copier-answers.yml— managed by Copier internally
State Files
dwe-state.json (DWE-managed)
Written by the dwe CLI after copier.run_copy(). Tracks DWE-specific metadata:
{
"dwe_version": "1.0.0",
"adapter": {
"name": "test_adapter",
"version": "v1.0.0",
"last_update": "2026-03-22"
},
"environments": ["development", "main"],
"infrastructure": "pulumi"
}
.copier-answers.yml (Copier-managed)
Written by Copier. Tracks the template source, version, and question answers. Do not edit manually. This is what enables copier.run_update() to know where the template came from.
# Changes here will be overwritten by copier
_commit: v1.0.0
_src_path: /path/to/my_adapter
project_name: acme-data-platform
aws_region: eu-west-1
instance_type: t3.small
Both files coexist. dwe-state.json is for DWE tooling; .copier-answers.yml is for Copier's update machinery.
Developer Workflow After create-service
Once the client repo is hydrated, the full developer loop is:
1. Local development (laptop):
git clone https://github.com/client/data-platform
cd data-platform
cp .env.example .env # fill in local values (no real AWS keys needed)
just up # docker compose up — app is running at localhost:8080
2. Provision cloud infrastructure (once):
# Fill in real AWS keys in .env
just install-infra # pip install pulumi pulumi-aws
just infra-preview # see what Pulumi will create
just infra-up # provision the EC2 instance
3. Deploy to EC2 (SSH into the instance, then):
git clone https://github.com/client/data-platform /srv/app
cd /srv/app
cp .env.example .env # fill in production values
just deploy-prod # docker compose -f ... up -d
4. CI/CD (automatic after push):
Pushing to development or main triggers the corresponding GitHub Actions workflow. See the CI/CD Workflow Design section below for the full two-path logic.
CI/CD Workflow Design
The generated CI/CD workflow (.github/workflows/deploy-{env}.yaml) implements a two-path logic inspired by the Superset production setup. The key insight: infrastructure changes and application changes require completely different responses.
The Two Paths
Push to branch
│
▼
Detect changes
(dorny/paths-filter)
│
├─── pulumi/** changed?
│ │
│ ├─ Pull Request → pulumi preview (validate, no apply)
│ └─ Push → pulumi up --yes (apply infra changes)
│
└─── docker-compose / blueprint changed?
AND pulumi NOT changed?
│
└─ Push → instance refresh (ASG) or VMSS update-instances
(VMs reboot from base image, no Pulumi run)
Why skip deploy when infra also changed? The pulumi up step re-provisions the EC2 instance itself, which already pulls the latest code via its user-data script. Running the app deploy on top of that would be redundant and potentially racy.
Job Summary
| Job | Trigger | What it does |
|---|---|---|
pulumi-preview |
PR, pulumi/** changed |
Runs pulumi preview — shows what would change, no side effects |
pulumi-apply |
Push, pulumi/** changed |
Runs pulumi up --yes + triggers instance refresh / VMSS update |
deploy-app |
Push, app files changed, pulumi/** NOT changed |
Triggers instance refresh (ASG) or VMSS update-instances (Azure) — skips Pulumi entirely |
Required Secrets
Set these via dwe set-secrets or manually in GitHub / GitLab repository settings. Use dwe show-secrets-template <adapter> to get the full list for a specific adapter.
AWS:
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID |
AWS credentials for Pulumi + ASG |
AWS_SECRET_ACCESS_KEY |
AWS credentials |
PULUMI_S3_STATE |
S3 backend URL, e.g. s3://my-pulumi-state-bucket |
Azure:
| Secret | Description |
|---|---|
AZURE_CLIENT_ID |
Service principal client ID |
AZURE_CLIENT_SECRET |
Service principal secret |
AZURE_TENANT_ID |
Azure AD tenant ID |
AZURE_SUBSCRIPTION_ID |
Azure subscription ID |
AZURE_STORAGE_ACCOUNT |
Storage account name for Pulumi state backend |
PULUMI_AZURE_STATE |
Azure Blob Storage URL, e.g. azblob://container-name |
KEY_VAULT_NAME |
Key Vault containing adapter runtime secrets |
RESOURCE_GROUP |
Resource group containing the VMSS |
Example: What Happens on a Typical Push
Scenario 1 — you edited docker-compose.yml or blueprint/instance-setup.sh:
Push to development branch
↓
detect-changes: pulumi=false, app=true
↓
deploy-app runs:
AWS: starts ASG instance refresh (rolling, waits for Successful)
Azure: az vmss update-instances --instance-ids '*' (waits for Succeeded)
↓
VMs reboot from base image, pick up latest code at startup
Scenario 2 — you changed pulumi/_azure.py (e.g. bigger VM SKU):
Push to development branch
↓
detect-changes: pulumi=true, app=false
↓
pulumi-apply runs:
pulumi up --yes
Azure auto-applies VMSS model change (Automatic upgrade policy)
az vmss update-instances triggered for belt-and-suspenders
↓
Infrastructure updated. New instance picks up latest code at boot.
Scenario 3 — you opened a PR with Pulumi changes:
Pull Request to development
↓
detect-changes: infrastructure=true
↓
pulumi-preview runs:
pulumi preview
Output shown in CI logs — no changes applied
↓
Reviewer can see exactly what Pulumi will do before merging.
Adapting for Other Platforms
The same two-path logic works for GitLab CI. The adapter's ci-templates/gitlab.yaml mirrors github.yaml exactly — same pulumi/** path filter, same three jobs (pulumi-preview, pulumi-apply, deploy-app), same cloud-provider branching with {% if CLOUD_PROVIDER == "azure" %}.
Adding a New Environment Later
Environments are set up at create-service time. To add one later:
# Create the branch
git checkout initial-commit
git checkout -b staging
git push origin staging
# Generate the workflow file
cp .github/workflows/deploy-development.yaml .github/workflows/deploy-staging.yaml
# Edit deploy-staging.yaml: change all occurrences of "development" to "staging"
git add .github/workflows/deploy-staging.yaml
git commit -m "chore: add staging environment"
git push
Releasing to PyPI
Two workflows handle the full release lifecycle:
bump version in pyproject.toml → merge to main
│
▼
tag-version.yml triggers on: push to main, pyproject.toml changed
reads Poetry version creates git tag vX.Y.Z automatically
│
▼
(go to GitHub → Releases → Draft a new release → publish it)
│
▼
pypi-publish.yml triggers on: release published
poetry build + publish pushes to PyPI via PYPI_TOKEN
One-time setup
Add PYPI_TOKEN to the repository secrets (Settings → Secrets → Actions):
- Go to https://pypi.org/manage/account/token/ and create an API token scoped to
dwe-core - In GitHub:
Settings → Secrets and variables → Actions → New repository secret- Name:
PYPI_TOKEN - Value: the token from PyPI (starts with
pypi-)
- Name:
Release flow
Step 1 — bump the version and merge to main:
poetry version patch # 1.0.0 → 1.0.1
poetry version minor # 1.0.0 → 1.1.0
poetry version major # 1.0.0 → 2.0.0
poetry version prerelease # 1.0.0 → 1.0.1a1
poetry version 1.2.0 # set explicit version
git add pyproject.toml
git commit -m "chore: bump version to $(poetry version -s)"
git push origin main
tag-version.yml fires on the push, reads the version from pyproject.toml, and pushes tag vX.Y.Z. No manual tagging needed, and it only runs on main.
Step 2 — publish the GitHub Release:
Go to github.com/<org>/dwe-core/releases, click Draft a new release, select the tag just created, and click Publish release.
pypi-publish.yml fires on the publish event: runs poetry install, poetry build, then poetry publish -u __token__ -p $PYPI_TOKEN.
Technical Stack
| Concern | Library |
|---|---|
| CLI framework | Typer |
| Template engine | Copier |
| Git operations | GitPython |
| GitHub secrets | PyGithub |
| GitLab variables | python-gitlab |
| Runtime templating | Jinja2 (for CI templates) |
| AWS Secrets Manager | boto3 |
| Infrastructure | Pulumi |
| Task runner | Just |
Metadata
Release files for dwe-core 0.1.14a3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dwe_core-0.1.14a3.tar.gz | 37.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dwe_core-0.1.14a3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 72.9 kB
Release files / dwe_core-0.1.14a3.tar.gz
| Download URL | dwe_core-0.1.14a3.tar.gz |
|---|---|
| Size | 37.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0970da51a836278cf3b2edce88c860ac13eb49ae7c19ec36b7ae4f24cdc35a68
|
|
BLAKE2b-256 checksum How to use checksums |
2723e55605f7d15e1a186ff5c042090ec13b754c02f50a41cf8c7ccc87434462
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.5.1 CPython/3.11.16 Linux/6.17.0-1022-azure
|
Release files / dwe_core-0.1.14a3-py3-none-any.whl
| Download URL | dwe_core-0.1.14a3-py3-none-any.whl |
|---|---|
| Size | 35.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4be064b02c4276614251a470c72847472b8e8a39ab10680714f50cc4b1dfa194
|
|
BLAKE2b-256 checksum How to use checksums |
452c2e4a401ddf8afc8e8a5fc96cbe048bacdfe8925d842a14df3d6dbdc7ebf1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.5.1 CPython/3.11.16 Linux/6.17.0-1022-azure
|