orchestra-cli 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/PKG-INFO +63 -3
  2. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/README.md +62 -2
  3. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/src/cli.py +4 -0
  4. orchestra_cli-0.2.0/orchestra_cli/src/create_pipeline.py +65 -0
  5. orchestra_cli-0.2.0/orchestra_cli/src/pipeline_upsert.py +74 -0
  6. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/src/run_pipeline.py +4 -0
  7. orchestra_cli-0.2.0/orchestra_cli/src/update_pipeline.py +65 -0
  8. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/tests/test_cli.py +2 -0
  9. orchestra_cli-0.2.0/orchestra_cli/tests/test_create_pipeline.py +177 -0
  10. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/tests/test_run_pipeline.py +2 -0
  11. orchestra_cli-0.2.0/orchestra_cli/tests/test_update_pipeline.py +176 -0
  12. orchestra_cli-0.2.0/orchestra_cli/utils/constants.py +28 -0
  13. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli.egg-info/PKG-INFO +63 -3
  14. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli.egg-info/SOURCES.txt +5 -0
  15. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/pyproject.toml +1 -1
  16. orchestra_cli-0.1.2/orchestra_cli/utils/constants.py +0 -7
  17. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/src/__init__.py +0 -0
  18. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/src/import_pipeline.py +0 -0
  19. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/src/validate_pipeline.py +0 -0
  20. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/tests/__init__.py +0 -0
  21. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/tests/conftest.py +0 -0
  22. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/tests/test_import_pipeline.py +0 -0
  23. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/tests/test_validate_pipeline.py +0 -0
  24. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/utils/git.py +0 -0
  25. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli/utils/styling.py +0 -0
  26. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli.egg-info/dependency_links.txt +0 -0
  27. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli.egg-info/entry_points.txt +0 -0
  28. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli.egg-info/requires.txt +0 -0
  29. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/orchestra_cli.egg-info/top_level.txt +0 -0
  30. {orchestra_cli-0.1.2 → orchestra_cli-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestra-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Orchestra CLI. Perform operations with Orchestra locally.
5
5
  Author-email: Orchestra Technologies <support@getorchestra.io>
6
6
  Requires-Python: >=3.10
@@ -15,6 +15,8 @@ Orchestra CLI for working with Orchestra pipelines from your terminal.
15
15
 
16
16
  Two entrypoints are available: `orchestra` and `orchestra-cli` (they are equivalent).
17
17
 
18
+ See [`AGENTS.md`](AGENTS.md) for contributor and AI agent guidance (environment setup, code conventions, testing).
19
+
18
20
  ## Installation
19
21
 
20
22
  ```bash
@@ -30,13 +32,14 @@ pipx install orchestra-cli
30
32
  ## Environment variables
31
33
 
32
34
  - `ORCHESTRA_API_KEY`: Required for actions that call the API (`import`, `run`).
33
- - `BASE_URL`: Optional. Override the default API base
34
- (`https://app.getorchestra.io/api/engine/public/pipelines/{}`) for non‑production/testing.
35
+ - `BASE_URL`: Optional. Override the default Orchestra host (`https://app.getorchestra.io`) for non‑production/testing.
35
36
 
36
37
  ## Commands overview
37
38
 
38
39
  - `validate`: Validate a pipeline YAML locally against the Orchestra API schema.
39
40
  - `import`: Register a pipeline YAML (from a git repo) with Orchestra under an alias.
41
+ - `create-pipeline`: Create an Orchestra-backed pipeline from a local YAML file.
42
+ - `update-pipeline`: Update an existing Orchestra-backed pipeline from a local YAML file.
40
43
  - `run`: Start a pipeline run by alias, optionally pinning branch/commit and waiting for completion.
41
44
 
42
45
  Use `orchestra --help` or `orchestra <command> --help` for built-in help.
@@ -118,6 +121,63 @@ Common errors
118
121
 
119
122
  ---
120
123
 
124
+ ## create-pipeline
125
+
126
+ Create an Orchestra-backed pipeline directly from a local YAML file.
127
+
128
+ ```bash
129
+ export ORCHESTRA_API_KEY=...
130
+
131
+ orchestra create-pipeline \
132
+ --alias my-pipeline \
133
+ --path ./pipelines/pipeline.yaml \
134
+ --publish # optional, defaults to --no-publish
135
+ ```
136
+
137
+ Options
138
+
139
+ - `-a, --alias` (required): Pipeline alias.
140
+ - `-p, --path` (required): Path to pipeline YAML file.
141
+ - `--publish/--no-publish` (optional, default `--no-publish`): Whether the pipeline is published.
142
+
143
+ Behavior
144
+
145
+ - Validates YAML against the Orchestra schema endpoint before creating.
146
+ - Sends pipeline data to `POST /pipelines` with `storage_provider=ORCHESTRA`.
147
+ - On success, prints the pipeline edit URL (`/pipelines/{pipeline_id}/edit`) when an ID is returned.
148
+ - Exit codes: `0` on success, `1` on failure.
149
+
150
+ ---
151
+
152
+ ## update-pipeline
153
+
154
+ Update an existing Orchestra-backed pipeline from a local YAML file.
155
+
156
+ ```bash
157
+ export ORCHESTRA_API_KEY=...
158
+
159
+ orchestra update-pipeline \
160
+ --alias my-pipeline \
161
+ --path ./pipelines/pipeline.yaml \
162
+ --no-publish # default
163
+ ```
164
+
165
+ Options
166
+
167
+ - `-a, --alias` (required): Pipeline alias to update.
168
+ - `-p, --path` (required): Path to pipeline YAML file.
169
+ - `--publish/--no-publish` (optional, default `--no-publish`): Whether the pipeline is published.
170
+
171
+ Behavior
172
+
173
+ - Validates YAML against the Orchestra schema endpoint before updating.
174
+ - Sends pipeline data to `PUT /pipelines/{alias}` with `storage_provider=ORCHESTRA`.
175
+ - Only Orchestra-backed pipelines can be updated via this endpoint (Git-backed pipelines are rejected).
176
+ - On success, prints the pipeline edit URL (`/pipelines/{pipeline_id}/edit`) when an ID is returned.
177
+ - Exit codes: `0` on success, `1` on failure.
178
+
179
+ ---
180
+
121
181
  ## run
122
182
 
123
183
  Start a pipeline run by alias. Optionally specify a branch and/or commit. By default, the command waits and polls the run status until completion.
@@ -4,6 +4,8 @@ Orchestra CLI for working with Orchestra pipelines from your terminal.
4
4
 
5
5
  Two entrypoints are available: `orchestra` and `orchestra-cli` (they are equivalent).
6
6
 
7
+ See [`AGENTS.md`](AGENTS.md) for contributor and AI agent guidance (environment setup, code conventions, testing).
8
+
7
9
  ## Installation
8
10
 
9
11
  ```bash
@@ -19,13 +21,14 @@ pipx install orchestra-cli
19
21
  ## Environment variables
20
22
 
21
23
  - `ORCHESTRA_API_KEY`: Required for actions that call the API (`import`, `run`).
22
- - `BASE_URL`: Optional. Override the default API base
23
- (`https://app.getorchestra.io/api/engine/public/pipelines/{}`) for non‑production/testing.
24
+ - `BASE_URL`: Optional. Override the default Orchestra host (`https://app.getorchestra.io`) for non‑production/testing.
24
25
 
25
26
  ## Commands overview
26
27
 
27
28
  - `validate`: Validate a pipeline YAML locally against the Orchestra API schema.
28
29
  - `import`: Register a pipeline YAML (from a git repo) with Orchestra under an alias.
30
+ - `create-pipeline`: Create an Orchestra-backed pipeline from a local YAML file.
31
+ - `update-pipeline`: Update an existing Orchestra-backed pipeline from a local YAML file.
29
32
  - `run`: Start a pipeline run by alias, optionally pinning branch/commit and waiting for completion.
30
33
 
31
34
  Use `orchestra --help` or `orchestra <command> --help` for built-in help.
@@ -107,6 +110,63 @@ Common errors
107
110
 
108
111
  ---
109
112
 
113
+ ## create-pipeline
114
+
115
+ Create an Orchestra-backed pipeline directly from a local YAML file.
116
+
117
+ ```bash
118
+ export ORCHESTRA_API_KEY=...
119
+
120
+ orchestra create-pipeline \
121
+ --alias my-pipeline \
122
+ --path ./pipelines/pipeline.yaml \
123
+ --publish # optional, defaults to --no-publish
124
+ ```
125
+
126
+ Options
127
+
128
+ - `-a, --alias` (required): Pipeline alias.
129
+ - `-p, --path` (required): Path to pipeline YAML file.
130
+ - `--publish/--no-publish` (optional, default `--no-publish`): Whether the pipeline is published.
131
+
132
+ Behavior
133
+
134
+ - Validates YAML against the Orchestra schema endpoint before creating.
135
+ - Sends pipeline data to `POST /pipelines` with `storage_provider=ORCHESTRA`.
136
+ - On success, prints the pipeline edit URL (`/pipelines/{pipeline_id}/edit`) when an ID is returned.
137
+ - Exit codes: `0` on success, `1` on failure.
138
+
139
+ ---
140
+
141
+ ## update-pipeline
142
+
143
+ Update an existing Orchestra-backed pipeline from a local YAML file.
144
+
145
+ ```bash
146
+ export ORCHESTRA_API_KEY=...
147
+
148
+ orchestra update-pipeline \
149
+ --alias my-pipeline \
150
+ --path ./pipelines/pipeline.yaml \
151
+ --no-publish # default
152
+ ```
153
+
154
+ Options
155
+
156
+ - `-a, --alias` (required): Pipeline alias to update.
157
+ - `-p, --path` (required): Path to pipeline YAML file.
158
+ - `--publish/--no-publish` (optional, default `--no-publish`): Whether the pipeline is published.
159
+
160
+ Behavior
161
+
162
+ - Validates YAML against the Orchestra schema endpoint before updating.
163
+ - Sends pipeline data to `PUT /pipelines/{alias}` with `storage_provider=ORCHESTRA`.
164
+ - Only Orchestra-backed pipelines can be updated via this endpoint (Git-backed pipelines are rejected).
165
+ - On success, prints the pipeline edit URL (`/pipelines/{pipeline_id}/edit`) when an ID is returned.
166
+ - Exit codes: `0` on success, `1` on failure.
167
+
168
+ ---
169
+
110
170
  ## run
111
171
 
112
172
  Start a pipeline run by alias. Optionally specify a branch and/or commit. By default, the command waits and polls the run status until completion.
@@ -1,7 +1,9 @@
1
1
  import typer
2
2
 
3
+ from .create_pipeline import create_pipeline
3
4
  from .import_pipeline import import_pipeline
4
5
  from .run_pipeline import run_pipeline
6
+ from .update_pipeline import update_pipeline
5
7
  from .validate_pipeline import validate
6
8
 
7
9
  app = typer.Typer(help="Orchestra CLI – perform operations with Orchestra locally.")
@@ -10,3 +12,5 @@ app = typer.Typer(help="Orchestra CLI – perform operations with Orchestra loca
10
12
  app.command(name="validate")(validate)
11
13
  app.command(name="import")(import_pipeline)
12
14
  app.command(name="run")(run_pipeline)
15
+ app.command(name="create-pipeline")(create_pipeline)
16
+ app.command(name="update-pipeline")(update_pipeline)
@@ -0,0 +1,65 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+ import typer
6
+
7
+ from ..utils.constants import get_create_pipeline_url
8
+ from ..utils.styling import indent_message, red, yellow
9
+ from .pipeline_upsert import (
10
+ build_upsert_payload,
11
+ emit_success_with_edit_url,
12
+ load_validated_pipeline_data,
13
+ require_api_key,
14
+ require_pipeline_id_from_success_response,
15
+ )
16
+
17
+
18
+ def create_pipeline(
19
+ alias: str = typer.Option(..., "--alias", "-a", help="Pipeline alias"),
20
+ path: Path = typer.Option(
21
+ ...,
22
+ "--path",
23
+ "-p",
24
+ exists=True,
25
+ file_okay=True,
26
+ dir_okay=False,
27
+ readable=True,
28
+ resolve_path=True,
29
+ help="Path to pipeline YAML",
30
+ ),
31
+ publish: bool = typer.Option(
32
+ False,
33
+ "--publish/--no-publish",
34
+ help="Whether the pipeline is published and can be triggered",
35
+ ),
36
+ ):
37
+ """
38
+ Create an Orchestra-backed pipeline from a local YAML file.
39
+ """
40
+ api_key = require_api_key()
41
+ data = load_validated_pipeline_data(path)
42
+ payload = build_upsert_payload(data, publish, alias=alias)
43
+
44
+ try:
45
+ response = httpx.post(
46
+ get_create_pipeline_url(),
47
+ json=payload,
48
+ timeout=30,
49
+ headers={"Authorization": f"Bearer {api_key}"},
50
+ )
51
+ except Exception as e:
52
+ typer.echo(red(f"HTTP request failed: {e}"))
53
+ raise typer.Exit(code=1)
54
+
55
+ if response.status_code == 201:
56
+ pipeline_id = require_pipeline_id_from_success_response(response, "Create")
57
+ emit_success_with_edit_url(alias, "created", pipeline_id)
58
+ raise typer.Exit(code=0)
59
+
60
+ typer.echo(red(f"❌ Create failed with status {response.status_code}"))
61
+ try:
62
+ typer.echo(yellow(indent_message(json.dumps(response.json(), indent=2))))
63
+ except Exception:
64
+ typer.echo(yellow(indent_message(response.text)))
65
+ raise typer.Exit(code=1)
@@ -0,0 +1,74 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from ..utils.constants import get_pipeline_edit_url
9
+ from ..utils.styling import green, indent_message, red, yellow
10
+ from .import_pipeline import _load_yaml, _validate_yaml_with_api
11
+
12
+
13
+ def require_api_key() -> str:
14
+ api_key = os.getenv("ORCHESTRA_API_KEY")
15
+ if not api_key:
16
+ typer.echo(red("ORCHESTRA_API_KEY is not set"))
17
+ raise typer.Exit(code=1)
18
+ return api_key
19
+
20
+
21
+ def load_validated_pipeline_data(path: Path) -> dict:
22
+ if not path.exists():
23
+ typer.echo(red(f"File not found: {path}"))
24
+ raise typer.Exit(code=1)
25
+
26
+ data, err = _load_yaml(path)
27
+ if err is not None:
28
+ typer.echo(red(f"Invalid YAML: {err}"))
29
+ raise typer.Exit(code=1)
30
+
31
+ ok, err_msg = _validate_yaml_with_api(data or {})
32
+ if not ok:
33
+ typer.echo(red("❌ Validation failed"))
34
+ if err_msg:
35
+ typer.echo(yellow(indent_message(err_msg)))
36
+ raise typer.Exit(code=1)
37
+
38
+ return data or {}
39
+
40
+
41
+ def build_upsert_payload(data: dict, publish: bool, alias: str | None = None) -> dict:
42
+ payload: dict[str, object] = {
43
+ "data": data,
44
+ "published": publish,
45
+ "storage_provider": "ORCHESTRA",
46
+ }
47
+ if alias is not None:
48
+ payload["alias"] = alias
49
+ return payload
50
+
51
+
52
+ def require_pipeline_id_from_success_response(
53
+ response: httpx.Response,
54
+ action: str,
55
+ ) -> str:
56
+ try:
57
+ body = response.json()
58
+ except Exception:
59
+ typer.echo(red(f"❌ {action} failed: success response was not valid JSON"))
60
+ typer.echo(yellow(indent_message(response.text)))
61
+ raise typer.Exit(code=1)
62
+
63
+ pipeline_id = body.get("id")
64
+ if not pipeline_id:
65
+ typer.echo(red(f"❌ {action} failed: success response did not include pipeline id"))
66
+ typer.echo(yellow(indent_message(json.dumps(body, indent=2))))
67
+ raise typer.Exit(code=1)
68
+
69
+ return str(pipeline_id)
70
+
71
+
72
+ def emit_success_with_edit_url(alias: str, action: str, pipeline_id: str) -> None:
73
+ typer.echo(green(f"✅ Pipeline '{alias}' {action} successfully: {pipeline_id}"))
74
+ typer.echo(yellow(f"Edit URL: {get_pipeline_edit_url(pipeline_id)}"))
@@ -106,6 +106,7 @@ def run_pipeline(
106
106
  poll_interval_seconds = 5
107
107
  headers = {"Authorization": f"Bearer {api_key}"}
108
108
  status_url = f"{api_prefix}/pipeline_runs/{pipeline_run_id}/status"
109
+ in_progress_statuses = {"RUNNING", "QUEUED", "CREATED"}
109
110
 
110
111
  while True:
111
112
  time.sleep(poll_interval_seconds)
@@ -158,6 +159,9 @@ def run_pipeline(
158
159
  typer.echo(yellow(lineage_url))
159
160
  raise typer.Exit(code=1)
160
161
 
162
+ if status_value in in_progress_statuses:
163
+ continue
164
+
161
165
  typer.echo(
162
166
  red(f"❌ Invalid status value: {status_value}\nResponse body: {status_body}"),
163
167
  )
@@ -0,0 +1,65 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+ import typer
6
+
7
+ from ..utils.constants import get_update_pipeline_url
8
+ from ..utils.styling import indent_message, red, yellow
9
+ from .pipeline_upsert import (
10
+ build_upsert_payload,
11
+ emit_success_with_edit_url,
12
+ load_validated_pipeline_data,
13
+ require_api_key,
14
+ require_pipeline_id_from_success_response,
15
+ )
16
+
17
+
18
+ def update_pipeline(
19
+ alias: str = typer.Option(..., "--alias", "-a", help="Pipeline alias"),
20
+ path: Path = typer.Option(
21
+ ...,
22
+ "--path",
23
+ "-p",
24
+ exists=True,
25
+ file_okay=True,
26
+ dir_okay=False,
27
+ readable=True,
28
+ resolve_path=True,
29
+ help="Path to pipeline YAML",
30
+ ),
31
+ publish: bool = typer.Option(
32
+ False,
33
+ "--publish/--no-publish",
34
+ help="Whether the pipeline is published and can be triggered",
35
+ ),
36
+ ):
37
+ """
38
+ Update an Orchestra-backed pipeline from a local YAML file.
39
+ """
40
+ api_key = require_api_key()
41
+ data = load_validated_pipeline_data(path)
42
+ payload = build_upsert_payload(data, publish)
43
+
44
+ try:
45
+ response = httpx.put(
46
+ get_update_pipeline_url(alias),
47
+ json=payload,
48
+ timeout=30,
49
+ headers={"Authorization": f"Bearer {api_key}"},
50
+ )
51
+ except Exception as e:
52
+ typer.echo(red(f"HTTP request failed: {e}"))
53
+ raise typer.Exit(code=1)
54
+
55
+ if response.status_code == 200:
56
+ pipeline_id = require_pipeline_id_from_success_response(response, "Update")
57
+ emit_success_with_edit_url(alias, "updated", pipeline_id)
58
+ raise typer.Exit(code=0)
59
+
60
+ typer.echo(red(f"❌ Update failed with status {response.status_code}"))
61
+ try:
62
+ typer.echo(yellow(indent_message(json.dumps(response.json(), indent=2))))
63
+ except Exception:
64
+ typer.echo(yellow(indent_message(response.text)))
65
+ raise typer.Exit(code=1)
@@ -10,3 +10,5 @@ def test_help():
10
10
  assert result.exit_code == 0
11
11
  assert "Orchestra CLI" in result.output
12
12
  assert "validate" in result.output
13
+ assert "create-pipeline" in result.output
14
+ assert "update-pipeline" in result.output
@@ -0,0 +1,177 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from pytest_httpx import HTTPXMock
5
+ from typer.testing import CliRunner
6
+
7
+ from orchestra_cli.src.cli import app
8
+
9
+ runner = CliRunner()
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def mock_env(monkeypatch):
14
+ monkeypatch.setenv("ORCHESTRA_API_KEY", "fake-key")
15
+ monkeypatch.setenv("BASE_URL", "")
16
+
17
+
18
+ def test_create_success_default_no_publish(tmp_path: Path, httpx_mock: HTTPXMock):
19
+ yaml_file = tmp_path / "pipe.yaml"
20
+ yaml_file.write_text("name: demo\nversion: 1\n")
21
+
22
+ httpx_mock.add_response(
23
+ method="POST",
24
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
25
+ json={"ok": True},
26
+ status_code=200,
27
+ )
28
+ httpx_mock.add_response(
29
+ method="POST",
30
+ url="https://app.getorchestra.io/api/engine/public/pipelines",
31
+ json={"id": "pipeline-id"},
32
+ status_code=201,
33
+ match_json={
34
+ "alias": "demo",
35
+ "data": {"name": "demo", "version": 1},
36
+ "published": False,
37
+ "storage_provider": "ORCHESTRA",
38
+ },
39
+ )
40
+
41
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(yaml_file)])
42
+ assert result.exit_code == 0
43
+ assert "created successfully" in result.output
44
+ assert "https://app.getorchestra.io/pipelines/pipeline-id/edit" in result.output
45
+
46
+
47
+ def test_create_publish_flag(tmp_path: Path, httpx_mock: HTTPXMock):
48
+ yaml_file = tmp_path / "pipe.yaml"
49
+ yaml_file.write_text("name: demo\nversion: 1\n")
50
+
51
+ httpx_mock.add_response(
52
+ method="POST",
53
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
54
+ json={"ok": True},
55
+ status_code=200,
56
+ )
57
+ httpx_mock.add_response(
58
+ method="POST",
59
+ url="https://app.getorchestra.io/api/engine/public/pipelines",
60
+ json={"id": "pipeline-id"},
61
+ status_code=201,
62
+ match_json={
63
+ "alias": "demo",
64
+ "data": {"name": "demo", "version": 1},
65
+ "published": True,
66
+ "storage_provider": "ORCHESTRA",
67
+ },
68
+ )
69
+
70
+ result = runner.invoke(
71
+ app,
72
+ ["create-pipeline", "--alias", "demo", "--path", str(yaml_file), "--publish"],
73
+ )
74
+ assert result.exit_code == 0
75
+ assert "created successfully" in result.output
76
+ assert "https://app.getorchestra.io/pipelines/pipeline-id/edit" in result.output
77
+
78
+
79
+ def test_create_missing_api_key(monkeypatch, tmp_path: Path):
80
+ yaml_file = tmp_path / "pipe.yaml"
81
+ yaml_file.write_text("name: demo\n")
82
+
83
+ monkeypatch.delenv("ORCHESTRA_API_KEY", raising=False)
84
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(yaml_file)])
85
+ assert result.exit_code == 1
86
+ assert "ORCHESTRA_API_KEY is not set" in result.output
87
+
88
+
89
+ def test_create_invalid_yaml(tmp_path: Path):
90
+ bad = tmp_path / "bad.yaml"
91
+ bad.write_text("name: [oops\n")
92
+
93
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(bad)])
94
+ assert result.exit_code == 1
95
+ assert "Invalid YAML" in result.output
96
+
97
+
98
+ def test_create_schema_validation_error(tmp_path: Path, httpx_mock: HTTPXMock):
99
+ yaml_file = tmp_path / "pipe.yaml"
100
+ yaml_file.write_text("name: demo\nversion: 1\n")
101
+
102
+ httpx_mock.add_response(
103
+ method="POST",
104
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
105
+ json={"detail": [{"loc": ["root"], "msg": "bad"}]},
106
+ status_code=400,
107
+ )
108
+
109
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(yaml_file)])
110
+ assert result.exit_code == 1
111
+ assert "Validation failed" in result.output
112
+
113
+
114
+ def test_create_api_error(tmp_path: Path, httpx_mock: HTTPXMock):
115
+ yaml_file = tmp_path / "pipe.yaml"
116
+ yaml_file.write_text("name: demo\nversion: 1\n")
117
+
118
+ httpx_mock.add_response(
119
+ method="POST",
120
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
121
+ json={"ok": True},
122
+ status_code=200,
123
+ )
124
+ httpx_mock.add_response(
125
+ method="POST",
126
+ url="https://app.getorchestra.io/api/engine/public/pipelines",
127
+ json={"detail": "bad"},
128
+ status_code=400,
129
+ )
130
+
131
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(yaml_file)])
132
+ assert result.exit_code == 1
133
+ assert "Create failed" in result.output
134
+
135
+
136
+ def test_create_success_without_pipeline_id_fails(tmp_path: Path, httpx_mock: HTTPXMock):
137
+ yaml_file = tmp_path / "pipe.yaml"
138
+ yaml_file.write_text("name: demo\nversion: 1\n")
139
+
140
+ httpx_mock.add_response(
141
+ method="POST",
142
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
143
+ json={"ok": True},
144
+ status_code=200,
145
+ )
146
+ httpx_mock.add_response(
147
+ method="POST",
148
+ url="https://app.getorchestra.io/api/engine/public/pipelines",
149
+ json={"alias": "demo"},
150
+ status_code=201,
151
+ )
152
+
153
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(yaml_file)])
154
+ assert result.exit_code == 1
155
+ assert "success response did not include pipeline id" in result.output
156
+
157
+
158
+ def test_create_success_with_invalid_json_fails(tmp_path: Path, httpx_mock: HTTPXMock):
159
+ yaml_file = tmp_path / "pipe.yaml"
160
+ yaml_file.write_text("name: demo\nversion: 1\n")
161
+
162
+ httpx_mock.add_response(
163
+ method="POST",
164
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
165
+ json={"ok": True},
166
+ status_code=200,
167
+ )
168
+ httpx_mock.add_response(
169
+ method="POST",
170
+ url="https://app.getorchestra.io/api/engine/public/pipelines",
171
+ text="ok",
172
+ status_code=201,
173
+ )
174
+
175
+ result = runner.invoke(app, ["create-pipeline", "--alias", "demo", "--path", str(yaml_file)])
176
+ assert result.exit_code == 1
177
+ assert "success response was not valid JSON" in result.output
@@ -187,6 +187,7 @@ def test_run_wait_success(httpx_mock: HTTPXMock, monkeypatch, tmp_path: Path):
187
187
  result.output.strip().splitlines()[1]
188
188
  == f"Started pipeline (alias: demo), run id: {mock_pipeline_run_id}"
189
189
  ) # noqa: E501
190
+ assert "Invalid status value: RUNNING" not in result.output
190
191
  assert result.output.strip().splitlines()[-2] == "✅ Pipeline succeeded"
191
192
  assert result.output.strip().splitlines()[-1] == mock_pipeline_run_id
192
193
 
@@ -225,6 +226,7 @@ def test_run_wait_failed(httpx_mock: HTTPXMock, monkeypatch, tmp_path: Path):
225
226
 
226
227
  result = runner.invoke(app, ["run", "--alias", "demo", "--wait"])
227
228
  assert result.exit_code == 1
229
+ assert "Invalid status value: RUNNING" not in result.output
228
230
  assert "status FAILED" in result.output
229
231
  assert "/pipeline-runs/run-xyz/lineage" in result.output
230
232
 
@@ -0,0 +1,176 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from pytest_httpx import HTTPXMock
5
+ from typer.testing import CliRunner
6
+
7
+ from orchestra_cli.src.cli import app
8
+
9
+ runner = CliRunner()
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def mock_env(monkeypatch):
14
+ monkeypatch.setenv("ORCHESTRA_API_KEY", "fake-key")
15
+ monkeypatch.setenv("BASE_URL", "")
16
+
17
+
18
+ def test_update_success_default_no_publish(tmp_path: Path, httpx_mock: HTTPXMock):
19
+ yaml_file = tmp_path / "pipe.yaml"
20
+ yaml_file.write_text("name: demo\nversion: 1\n")
21
+
22
+ httpx_mock.add_response(
23
+ method="POST",
24
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
25
+ json={"ok": True},
26
+ status_code=200,
27
+ )
28
+ httpx_mock.add_response(
29
+ method="PUT",
30
+ url="https://app.getorchestra.io/api/engine/public/pipelines/demo",
31
+ json={"id": "pipeline-id"},
32
+ status_code=200,
33
+ match_json={
34
+ "data": {"name": "demo", "version": 1},
35
+ "published": False,
36
+ "storage_provider": "ORCHESTRA",
37
+ },
38
+ )
39
+
40
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(yaml_file)])
41
+ assert result.exit_code == 0
42
+ assert "updated successfully" in result.output
43
+ assert "https://app.getorchestra.io/pipelines/pipeline-id/edit" in result.output
44
+
45
+
46
+ def test_update_publish_flag(tmp_path: Path, httpx_mock: HTTPXMock):
47
+ yaml_file = tmp_path / "pipe.yaml"
48
+ yaml_file.write_text("name: demo\nversion: 1\n")
49
+
50
+ httpx_mock.add_response(
51
+ method="POST",
52
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
53
+ json={"ok": True},
54
+ status_code=200,
55
+ )
56
+ httpx_mock.add_response(
57
+ method="PUT",
58
+ url="https://app.getorchestra.io/api/engine/public/pipelines/demo",
59
+ json={"id": "pipeline-id"},
60
+ status_code=200,
61
+ match_json={
62
+ "data": {"name": "demo", "version": 1},
63
+ "published": True,
64
+ "storage_provider": "ORCHESTRA",
65
+ },
66
+ )
67
+
68
+ result = runner.invoke(
69
+ app,
70
+ ["update-pipeline", "--alias", "demo", "--path", str(yaml_file), "--publish"],
71
+ )
72
+ assert result.exit_code == 0
73
+ assert "updated successfully" in result.output
74
+ assert "https://app.getorchestra.io/pipelines/pipeline-id/edit" in result.output
75
+
76
+
77
+ def test_update_missing_api_key(monkeypatch, tmp_path: Path):
78
+ yaml_file = tmp_path / "pipe.yaml"
79
+ yaml_file.write_text("name: demo\n")
80
+
81
+ monkeypatch.delenv("ORCHESTRA_API_KEY", raising=False)
82
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(yaml_file)])
83
+ assert result.exit_code == 1
84
+ assert "ORCHESTRA_API_KEY is not set" in result.output
85
+
86
+
87
+ def test_update_invalid_yaml(tmp_path: Path):
88
+ bad = tmp_path / "bad.yaml"
89
+ bad.write_text("name: [oops\n")
90
+
91
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(bad)])
92
+ assert result.exit_code == 1
93
+ assert "Invalid YAML" in result.output
94
+
95
+
96
+ def test_update_schema_validation_error(tmp_path: Path, httpx_mock: HTTPXMock):
97
+ yaml_file = tmp_path / "pipe.yaml"
98
+ yaml_file.write_text("name: demo\nversion: 1\n")
99
+
100
+ httpx_mock.add_response(
101
+ method="POST",
102
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
103
+ json={"detail": [{"loc": ["root"], "msg": "bad"}]},
104
+ status_code=400,
105
+ )
106
+
107
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(yaml_file)])
108
+ assert result.exit_code == 1
109
+ assert "Validation failed" in result.output
110
+
111
+
112
+ def test_update_api_error_orchestra_backed_only(tmp_path: Path, httpx_mock: HTTPXMock):
113
+ yaml_file = tmp_path / "pipe.yaml"
114
+ yaml_file.write_text("name: demo\nversion: 1\n")
115
+
116
+ httpx_mock.add_response(
117
+ method="POST",
118
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
119
+ json={"ok": True},
120
+ status_code=200,
121
+ )
122
+ httpx_mock.add_response(
123
+ method="PUT",
124
+ url="https://app.getorchestra.io/api/engine/public/pipelines/demo",
125
+ json={"detail": "Only orchestra-backed pipelines can be updated via this endpoint."},
126
+ status_code=400,
127
+ )
128
+
129
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(yaml_file)])
130
+ assert result.exit_code == 1
131
+ assert "Update failed" in result.output
132
+ assert "Only orchestra-backed pipelines can be updated via this endpoint." in result.output
133
+
134
+
135
+ def test_update_success_without_pipeline_id_fails(tmp_path: Path, httpx_mock: HTTPXMock):
136
+ yaml_file = tmp_path / "pipe.yaml"
137
+ yaml_file.write_text("name: demo\nversion: 1\n")
138
+
139
+ httpx_mock.add_response(
140
+ method="POST",
141
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
142
+ json={"ok": True},
143
+ status_code=200,
144
+ )
145
+ httpx_mock.add_response(
146
+ method="PUT",
147
+ url="https://app.getorchestra.io/api/engine/public/pipelines/demo",
148
+ json={"alias": "demo"},
149
+ status_code=200,
150
+ )
151
+
152
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(yaml_file)])
153
+ assert result.exit_code == 1
154
+ assert "success response did not include pipeline id" in result.output
155
+
156
+
157
+ def test_update_success_with_invalid_json_fails(tmp_path: Path, httpx_mock: HTTPXMock):
158
+ yaml_file = tmp_path / "pipe.yaml"
159
+ yaml_file.write_text("name: demo\nversion: 1\n")
160
+
161
+ httpx_mock.add_response(
162
+ method="POST",
163
+ url="https://app.getorchestra.io/api/engine/public/pipelines/schema",
164
+ json={"ok": True},
165
+ status_code=200,
166
+ )
167
+ httpx_mock.add_response(
168
+ method="PUT",
169
+ url="https://app.getorchestra.io/api/engine/public/pipelines/demo",
170
+ text="ok",
171
+ status_code=200,
172
+ )
173
+
174
+ result = runner.invoke(app, ["update-pipeline", "--alias", "demo", "--path", str(yaml_file)])
175
+ assert result.exit_code == 1
176
+ assert "success response was not valid JSON" in result.output
@@ -0,0 +1,28 @@
1
+ import os
2
+
3
+ _PROD_BASE_URL = "https://app.getorchestra.io"
4
+
5
+
6
+ def get_api_url(path: str) -> str:
7
+ suffix = f"/{path.lstrip('/')}" if path else ""
8
+ return f"{get_base_url()}/api/engine/public/pipelines{suffix}"
9
+
10
+
11
+ def get_base_url() -> str:
12
+ return (os.getenv("BASE_URL") or _PROD_BASE_URL).strip().rstrip("/")
13
+
14
+
15
+ def get_public_api_url(path: str) -> str:
16
+ return f"{get_base_url()}/api/engine/public/{path.lstrip('/')}"
17
+
18
+
19
+ def get_create_pipeline_url() -> str:
20
+ return get_api_url("")
21
+
22
+
23
+ def get_update_pipeline_url(alias: str) -> str:
24
+ return get_api_url(alias)
25
+
26
+
27
+ def get_pipeline_edit_url(pipeline_id: str) -> str:
28
+ return f"{get_base_url()}/pipelines/{pipeline_id}/edit"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestra-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Orchestra CLI. Perform operations with Orchestra locally.
5
5
  Author-email: Orchestra Technologies <support@getorchestra.io>
6
6
  Requires-Python: >=3.10
@@ -15,6 +15,8 @@ Orchestra CLI for working with Orchestra pipelines from your terminal.
15
15
 
16
16
  Two entrypoints are available: `orchestra` and `orchestra-cli` (they are equivalent).
17
17
 
18
+ See [`AGENTS.md`](AGENTS.md) for contributor and AI agent guidance (environment setup, code conventions, testing).
19
+
18
20
  ## Installation
19
21
 
20
22
  ```bash
@@ -30,13 +32,14 @@ pipx install orchestra-cli
30
32
  ## Environment variables
31
33
 
32
34
  - `ORCHESTRA_API_KEY`: Required for actions that call the API (`import`, `run`).
33
- - `BASE_URL`: Optional. Override the default API base
34
- (`https://app.getorchestra.io/api/engine/public/pipelines/{}`) for non‑production/testing.
35
+ - `BASE_URL`: Optional. Override the default Orchestra host (`https://app.getorchestra.io`) for non‑production/testing.
35
36
 
36
37
  ## Commands overview
37
38
 
38
39
  - `validate`: Validate a pipeline YAML locally against the Orchestra API schema.
39
40
  - `import`: Register a pipeline YAML (from a git repo) with Orchestra under an alias.
41
+ - `create-pipeline`: Create an Orchestra-backed pipeline from a local YAML file.
42
+ - `update-pipeline`: Update an existing Orchestra-backed pipeline from a local YAML file.
40
43
  - `run`: Start a pipeline run by alias, optionally pinning branch/commit and waiting for completion.
41
44
 
42
45
  Use `orchestra --help` or `orchestra <command> --help` for built-in help.
@@ -118,6 +121,63 @@ Common errors
118
121
 
119
122
  ---
120
123
 
124
+ ## create-pipeline
125
+
126
+ Create an Orchestra-backed pipeline directly from a local YAML file.
127
+
128
+ ```bash
129
+ export ORCHESTRA_API_KEY=...
130
+
131
+ orchestra create-pipeline \
132
+ --alias my-pipeline \
133
+ --path ./pipelines/pipeline.yaml \
134
+ --publish # optional, defaults to --no-publish
135
+ ```
136
+
137
+ Options
138
+
139
+ - `-a, --alias` (required): Pipeline alias.
140
+ - `-p, --path` (required): Path to pipeline YAML file.
141
+ - `--publish/--no-publish` (optional, default `--no-publish`): Whether the pipeline is published.
142
+
143
+ Behavior
144
+
145
+ - Validates YAML against the Orchestra schema endpoint before creating.
146
+ - Sends pipeline data to `POST /pipelines` with `storage_provider=ORCHESTRA`.
147
+ - On success, prints the pipeline edit URL (`/pipelines/{pipeline_id}/edit`) when an ID is returned.
148
+ - Exit codes: `0` on success, `1` on failure.
149
+
150
+ ---
151
+
152
+ ## update-pipeline
153
+
154
+ Update an existing Orchestra-backed pipeline from a local YAML file.
155
+
156
+ ```bash
157
+ export ORCHESTRA_API_KEY=...
158
+
159
+ orchestra update-pipeline \
160
+ --alias my-pipeline \
161
+ --path ./pipelines/pipeline.yaml \
162
+ --no-publish # default
163
+ ```
164
+
165
+ Options
166
+
167
+ - `-a, --alias` (required): Pipeline alias to update.
168
+ - `-p, --path` (required): Path to pipeline YAML file.
169
+ - `--publish/--no-publish` (optional, default `--no-publish`): Whether the pipeline is published.
170
+
171
+ Behavior
172
+
173
+ - Validates YAML against the Orchestra schema endpoint before updating.
174
+ - Sends pipeline data to `PUT /pipelines/{alias}` with `storage_provider=ORCHESTRA`.
175
+ - Only Orchestra-backed pipelines can be updated via this endpoint (Git-backed pipelines are rejected).
176
+ - On success, prints the pipeline edit URL (`/pipelines/{pipeline_id}/edit`) when an ID is returned.
177
+ - Exit codes: `0` on success, `1` on failure.
178
+
179
+ ---
180
+
121
181
  ## run
122
182
 
123
183
  Start a pipeline run by alias. Optionally specify a branch and/or commit. By default, the command waits and polls the run status until completion.
@@ -8,14 +8,19 @@ orchestra_cli.egg-info/requires.txt
8
8
  orchestra_cli.egg-info/top_level.txt
9
9
  orchestra_cli/src/__init__.py
10
10
  orchestra_cli/src/cli.py
11
+ orchestra_cli/src/create_pipeline.py
11
12
  orchestra_cli/src/import_pipeline.py
13
+ orchestra_cli/src/pipeline_upsert.py
12
14
  orchestra_cli/src/run_pipeline.py
15
+ orchestra_cli/src/update_pipeline.py
13
16
  orchestra_cli/src/validate_pipeline.py
14
17
  orchestra_cli/tests/__init__.py
15
18
  orchestra_cli/tests/conftest.py
16
19
  orchestra_cli/tests/test_cli.py
20
+ orchestra_cli/tests/test_create_pipeline.py
17
21
  orchestra_cli/tests/test_import_pipeline.py
18
22
  orchestra_cli/tests/test_run_pipeline.py
23
+ orchestra_cli/tests/test_update_pipeline.py
19
24
  orchestra_cli/tests/test_validate_pipeline.py
20
25
  orchestra_cli/utils/constants.py
21
26
  orchestra_cli/utils/git.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "orchestra-cli"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Orchestra CLI. Perform operations with Orchestra locally."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,7 +0,0 @@
1
- import os
2
-
3
- _PROD_BASE_URL = "https://app.getorchestra.io/api/engine/public/pipelines/{}"
4
-
5
-
6
- def get_api_url(path: str) -> str:
7
- return (os.getenv("BASE_URL") or _PROD_BASE_URL).format(path)
File without changes