forjio-depllo 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.
- forjio_depllo-0.2.0/.gitignore +21 -0
- forjio_depllo-0.2.0/CHANGELOG.md +5 -0
- forjio_depllo-0.2.0/PKG-INFO +89 -0
- forjio_depllo-0.2.0/README.md +74 -0
- forjio_depllo-0.2.0/forjio_depllo/__init__.py +6 -0
- forjio_depllo-0.2.0/forjio_depllo/api_generated.py +337 -0
- forjio_depllo-0.2.0/forjio_depllo/client.py +166 -0
- forjio_depllo-0.2.0/forjio_depllo/errors.py +43 -0
- forjio_depllo-0.2.0/pyproject.toml +26 -0
- forjio_depllo-0.2.0/tests/test_api_generated.py +99 -0
- forjio_depllo-0.2.0/tests/test_client.py +89 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
.env
|
|
5
|
+
.env.local
|
|
6
|
+
.env.*.local
|
|
7
|
+
*.log
|
|
8
|
+
.DS_Store
|
|
9
|
+
*.swp
|
|
10
|
+
coverage/
|
|
11
|
+
playwright-report/
|
|
12
|
+
test-results/
|
|
13
|
+
tsconfig.tsbuildinfo
|
|
14
|
+
|
|
15
|
+
# apigen scratch copy (scripts/apigen.sh)
|
|
16
|
+
backend/.apigen/
|
|
17
|
+
|
|
18
|
+
# Python SDK (sdks/python) caches
|
|
19
|
+
__pycache__/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
*.egg-info/
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: forjio-depllo
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Depllo SDK — Python client for the Depllo CI/CD REST API. Sister to @forjio/depllo (JS).
|
|
5
|
+
Project-URL: Homepage, https://depllo.forjio.com/docs/sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/hachimi-cat/saas-depllo
|
|
7
|
+
Author-email: Forjio <support@forjio.com>
|
|
8
|
+
License: Proprietary
|
|
9
|
+
Keywords: cd,ci,depllo,forjio,pipelines,sdk
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# forjio-depllo
|
|
17
|
+
|
|
18
|
+
Python SDK for [Depllo](https://depllo.forjio.com) — GitLab-CI-style CI/CD
|
|
19
|
+
for your GitHub repos. The sister of the [`@forjio/depllo`](https://www.npmjs.com/package/@forjio/depllo)
|
|
20
|
+
JS SDK, with the same surface and behaviour.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install forjio-depllo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import os
|
|
28
|
+
from forjio_depllo import DeplloClient, DeplloError
|
|
29
|
+
|
|
30
|
+
# Bearer token from token= or the DEPLLO_TOKEN env var — use an sk_live_…
|
|
31
|
+
# API key from Dashboard → API Keys (a Huudis access token works too).
|
|
32
|
+
depllo = DeplloClient(token=os.environ["DEPLLO_TOKEN"])
|
|
33
|
+
|
|
34
|
+
projects = depllo.api.projects_list()["data"]
|
|
35
|
+
|
|
36
|
+
# Run a pipeline
|
|
37
|
+
run = depllo.api.projects_create_pipelines(
|
|
38
|
+
projects[0]["id"], ref="main", variables={"DEPLOY_ENV": "staging"}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Tail a job's log, check usage
|
|
42
|
+
log = depllo.api.jobs_log("job_01hx…", from_=0)["data"]
|
|
43
|
+
usage = depllo.api.usage_list()["data"]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
depllo.api.pipelines_cancel("pipe_missing")
|
|
47
|
+
except DeplloError as e:
|
|
48
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What you get back
|
|
52
|
+
|
|
53
|
+
Like the JS SDK, every method returns the Forjio envelope as a dict —
|
|
54
|
+
`{"data": …, "error": None, "meta": {"requestId": …}}` — so read `["data"]`.
|
|
55
|
+
A route that answers with bytes (a job artifact, a badge SVG) returns them as
|
|
56
|
+
the data: `{"data": {"data": bytes, "contentType": …, "filename": …}, "error": None}`.
|
|
57
|
+
A failed HTTP response raises `DeplloError` carrying the API's `code`
|
|
58
|
+
(`NOT_FOUND`, `VALIDATION_ERROR`, `INVALID_TOKEN`, …), the HTTP `status`, the
|
|
59
|
+
`message` and the `request_id`. Transport failures raise `DeplloError` with
|
|
60
|
+
code `NETWORK_ERROR` / `TIMEOUT` and status `0`. Mutating calls send an
|
|
61
|
+
`Idempotency-Key` automatically.
|
|
62
|
+
|
|
63
|
+
## `client.api` — every feature route
|
|
64
|
+
|
|
65
|
+
`client.api` has one method per Depllo feature route, named
|
|
66
|
+
`<area>_<action>` — the same names as the CLI's `depllo api <area> <action>`
|
|
67
|
+
commands. It is generated from the API spec (made from Depllo's own code), so
|
|
68
|
+
it always covers the whole API: projects, pipelines, jobs, runners, schedules,
|
|
69
|
+
variables, CI config, audit log, usage, billing and API keys. Path parameters
|
|
70
|
+
are positional; query and body fields are keyword arguments (`json_body=`
|
|
71
|
+
passes a whole body, fields given as arguments override it).
|
|
72
|
+
|
|
73
|
+
See the [API reference](https://depllo.forjio.com/docs/api/reference) for every
|
|
74
|
+
route and its fields, and [API authentication](https://depllo.forjio.com/docs/api-auth)
|
|
75
|
+
for keys.
|
|
76
|
+
|
|
77
|
+
## Options
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
DeplloClient(
|
|
81
|
+
token=None, # default: $DEPLLO_TOKEN
|
|
82
|
+
base_url="https://depllo.forjio.com/api/v1", # e.g. http://localhost:4200/api/v1
|
|
83
|
+
timeout=30.0,
|
|
84
|
+
http_client=None, # your own httpx.Client
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`client.request(method, path, query=None, body=None)` sends any other call
|
|
89
|
+
(paths relative to the base URL, e.g. `"/projects"`).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# forjio-depllo
|
|
2
|
+
|
|
3
|
+
Python SDK for [Depllo](https://depllo.forjio.com) — GitLab-CI-style CI/CD
|
|
4
|
+
for your GitHub repos. The sister of the [`@forjio/depllo`](https://www.npmjs.com/package/@forjio/depllo)
|
|
5
|
+
JS SDK, with the same surface and behaviour.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install forjio-depllo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import os
|
|
13
|
+
from forjio_depllo import DeplloClient, DeplloError
|
|
14
|
+
|
|
15
|
+
# Bearer token from token= or the DEPLLO_TOKEN env var — use an sk_live_…
|
|
16
|
+
# API key from Dashboard → API Keys (a Huudis access token works too).
|
|
17
|
+
depllo = DeplloClient(token=os.environ["DEPLLO_TOKEN"])
|
|
18
|
+
|
|
19
|
+
projects = depllo.api.projects_list()["data"]
|
|
20
|
+
|
|
21
|
+
# Run a pipeline
|
|
22
|
+
run = depllo.api.projects_create_pipelines(
|
|
23
|
+
projects[0]["id"], ref="main", variables={"DEPLOY_ENV": "staging"}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Tail a job's log, check usage
|
|
27
|
+
log = depllo.api.jobs_log("job_01hx…", from_=0)["data"]
|
|
28
|
+
usage = depllo.api.usage_list()["data"]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
depllo.api.pipelines_cancel("pipe_missing")
|
|
32
|
+
except DeplloError as e:
|
|
33
|
+
print(e.status, e.code, e.message, e.request_id)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What you get back
|
|
37
|
+
|
|
38
|
+
Like the JS SDK, every method returns the Forjio envelope as a dict —
|
|
39
|
+
`{"data": …, "error": None, "meta": {"requestId": …}}` — so read `["data"]`.
|
|
40
|
+
A route that answers with bytes (a job artifact, a badge SVG) returns them as
|
|
41
|
+
the data: `{"data": {"data": bytes, "contentType": …, "filename": …}, "error": None}`.
|
|
42
|
+
A failed HTTP response raises `DeplloError` carrying the API's `code`
|
|
43
|
+
(`NOT_FOUND`, `VALIDATION_ERROR`, `INVALID_TOKEN`, …), the HTTP `status`, the
|
|
44
|
+
`message` and the `request_id`. Transport failures raise `DeplloError` with
|
|
45
|
+
code `NETWORK_ERROR` / `TIMEOUT` and status `0`. Mutating calls send an
|
|
46
|
+
`Idempotency-Key` automatically.
|
|
47
|
+
|
|
48
|
+
## `client.api` — every feature route
|
|
49
|
+
|
|
50
|
+
`client.api` has one method per Depllo feature route, named
|
|
51
|
+
`<area>_<action>` — the same names as the CLI's `depllo api <area> <action>`
|
|
52
|
+
commands. It is generated from the API spec (made from Depllo's own code), so
|
|
53
|
+
it always covers the whole API: projects, pipelines, jobs, runners, schedules,
|
|
54
|
+
variables, CI config, audit log, usage, billing and API keys. Path parameters
|
|
55
|
+
are positional; query and body fields are keyword arguments (`json_body=`
|
|
56
|
+
passes a whole body, fields given as arguments override it).
|
|
57
|
+
|
|
58
|
+
See the [API reference](https://depllo.forjio.com/docs/api/reference) for every
|
|
59
|
+
route and its fields, and [API authentication](https://depllo.forjio.com/docs/api-auth)
|
|
60
|
+
for keys.
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
DeplloClient(
|
|
66
|
+
token=None, # default: $DEPLLO_TOKEN
|
|
67
|
+
base_url="https://depllo.forjio.com/api/v1", # e.g. http://localhost:4200/api/v1
|
|
68
|
+
timeout=30.0,
|
|
69
|
+
http_client=None, # your own httpx.Client
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`client.request(method, path, query=None, body=None)` sends any other call
|
|
74
|
+
(paths relative to the base URL, e.g. `"/projects"`).
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Every Depllo feature route, one method each — generated by apigen from
|
|
2
|
+
backend/openapi.json. Do not edit by hand; regenerate after the API changes.
|
|
3
|
+
|
|
4
|
+
Reach them as ``client.api.<area>_<action>(...)``. Each call goes through the
|
|
5
|
+
client's own ``_apigen_request`` (its sign-in and response envelope).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GeneratedApi:
|
|
14
|
+
"""All 45 feature routes of the Depllo API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client: Any) -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def _call(self, method: str, path: str, query: Dict[str, Any], body: Optional[Dict[str, Any]]) -> Any:
|
|
20
|
+
query = {k: v for k, v in query.items() if v is not None}
|
|
21
|
+
return self._client._apigen_request(method, path, query=query or None, body=body)
|
|
22
|
+
|
|
23
|
+
def api_keys_create(self, *, name: Optional[str] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
24
|
+
"""Create an API key. (POST /api/v1/api-keys).
|
|
25
|
+
|
|
26
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
27
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
28
|
+
if name is not None:
|
|
29
|
+
payload["name"] = name
|
|
30
|
+
if "name" not in payload:
|
|
31
|
+
raise ValueError("api_keys_create needs name")
|
|
32
|
+
return self._call("POST", f"/api/v1/api-keys", {}, payload)
|
|
33
|
+
|
|
34
|
+
def api_keys_delete(self, id_: str) -> Any:
|
|
35
|
+
"""Revoke an API key: requests with it get 401 from now on. (DELETE /api/v1/api-keys/{id})."""
|
|
36
|
+
return self._call("DELETE", f"/api/v1/api-keys/{_q(id_)}", {}, None)
|
|
37
|
+
|
|
38
|
+
def api_keys_list(self) -> Any:
|
|
39
|
+
"""List the workspace's active API keys (newest first). (GET /api/v1/api-keys)."""
|
|
40
|
+
return self._call("GET", f"/api/v1/api-keys", {}, None)
|
|
41
|
+
|
|
42
|
+
def audit_list(self, *, action: Optional[Any] = None, actor: Optional[Any] = None, q: Optional[Any] = None) -> Any:
|
|
43
|
+
"""List audit (GET /api/v1/audit)."""
|
|
44
|
+
return self._call("GET", f"/api/v1/audit", {"action": action, "actor": actor, "q": q}, None)
|
|
45
|
+
|
|
46
|
+
def billing_checkout(self, *, tier: Optional[str] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
47
|
+
"""POST /checkout {tier} — create a Plugipay hosted checkout session for a paid tier; the browser redirects to data.hostedUrl. (POST /api/v1/billing/checkout).
|
|
48
|
+
|
|
49
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it).
|
|
50
|
+
tier: one of free, starter, growth, business"""
|
|
51
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
52
|
+
if tier is not None:
|
|
53
|
+
payload["tier"] = tier
|
|
54
|
+
if "tier" not in payload:
|
|
55
|
+
raise ValueError("billing_checkout needs tier")
|
|
56
|
+
return self._call("POST", f"/api/v1/billing/checkout", {}, payload)
|
|
57
|
+
|
|
58
|
+
def billing_list(self) -> Any:
|
|
59
|
+
"""Current subscription (free default) + the tier table. (GET /api/v1/billing)."""
|
|
60
|
+
return self._call("GET", f"/api/v1/billing", {}, None)
|
|
61
|
+
|
|
62
|
+
def ci_lint(self, *, config_text: Optional[str] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
63
|
+
"""Create a lint (POST /api/v1/ci/lint).
|
|
64
|
+
|
|
65
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
66
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
67
|
+
if config_text is not None:
|
|
68
|
+
payload["configText"] = config_text
|
|
69
|
+
if "configText" not in payload:
|
|
70
|
+
raise ValueError("ci_lint needs config_text")
|
|
71
|
+
return self._call("POST", f"/api/v1/ci/lint", {}, payload)
|
|
72
|
+
|
|
73
|
+
def jobs_artifacts_download(self, id_: str, art_id: str) -> Any:
|
|
74
|
+
"""List download (GET /api/v1/jobs/{id}/artifacts/{artId}/download)."""
|
|
75
|
+
return self._call("GET", f"/api/v1/jobs/{_q(id_)}/artifacts/{_q(art_id)}/download", {}, None)
|
|
76
|
+
|
|
77
|
+
def jobs_cancel(self, id_: str) -> Any:
|
|
78
|
+
"""Cancel a job (POST /api/v1/jobs/{id}/cancel)."""
|
|
79
|
+
return self._call("POST", f"/api/v1/jobs/{_q(id_)}/cancel", {}, None)
|
|
80
|
+
|
|
81
|
+
def jobs_get(self, id_: str) -> Any:
|
|
82
|
+
"""Get a job (GET /api/v1/jobs/{id})."""
|
|
83
|
+
return self._call("GET", f"/api/v1/jobs/{_q(id_)}", {}, None)
|
|
84
|
+
|
|
85
|
+
def jobs_log(self, id_: str, *, from_: Optional[Any] = None) -> Any:
|
|
86
|
+
"""List log (GET /api/v1/jobs/{id}/log)."""
|
|
87
|
+
return self._call("GET", f"/api/v1/jobs/{_q(id_)}/log", {"from": from_}, None)
|
|
88
|
+
|
|
89
|
+
def jobs_log_stream(self, id_: str, *, from_: Optional[Any] = None) -> Any:
|
|
90
|
+
"""SSE tail: replays chunks from ?from= then polls the DB every ~1s. (GET /api/v1/jobs/{id}/log/stream)."""
|
|
91
|
+
return self._call("GET", f"/api/v1/jobs/{_q(id_)}/log/stream", {"from": from_}, None)
|
|
92
|
+
|
|
93
|
+
def jobs_play(self, id_: str) -> Any:
|
|
94
|
+
"""Play a job (POST /api/v1/jobs/{id}/play)."""
|
|
95
|
+
return self._call("POST", f"/api/v1/jobs/{_q(id_)}/play", {}, None)
|
|
96
|
+
|
|
97
|
+
def jobs_retry(self, id_: str) -> Any:
|
|
98
|
+
"""Retry a job (POST /api/v1/jobs/{id}/retry)."""
|
|
99
|
+
return self._call("POST", f"/api/v1/jobs/{_q(id_)}/retry", {}, None)
|
|
100
|
+
|
|
101
|
+
def pipelines_cancel(self, id_: str) -> Any:
|
|
102
|
+
"""Cancel a pipeline (POST /api/v1/pipelines/{id}/cancel)."""
|
|
103
|
+
return self._call("POST", f"/api/v1/pipelines/{_q(id_)}/cancel", {}, None)
|
|
104
|
+
|
|
105
|
+
def pipelines_get(self, id_: str) -> Any:
|
|
106
|
+
"""Get a pipeline (GET /api/v1/pipelines/{id})."""
|
|
107
|
+
return self._call("GET", f"/api/v1/pipelines/{_q(id_)}", {}, None)
|
|
108
|
+
|
|
109
|
+
def pipelines_retry(self, id_: str) -> Any:
|
|
110
|
+
"""Retry a pipeline (POST /api/v1/pipelines/{id}/retry)."""
|
|
111
|
+
return self._call("POST", f"/api/v1/pipelines/{_q(id_)}/retry", {}, None)
|
|
112
|
+
|
|
113
|
+
def projects_config(self, id_: str) -> Any:
|
|
114
|
+
"""List config (GET /api/v1/projects/{id}/config)."""
|
|
115
|
+
return self._call("GET", f"/api/v1/projects/{_q(id_)}/config", {}, None)
|
|
116
|
+
|
|
117
|
+
def projects_create(self, *, name: Optional[str] = None, repo_full_name: Optional[str] = None, default_branch: Optional[str] = None, config_path: Optional[str] = None, clone_token: Optional[str] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
118
|
+
"""Create a project (POST /api/v1/projects).
|
|
119
|
+
|
|
120
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
121
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
122
|
+
if name is not None:
|
|
123
|
+
payload["name"] = name
|
|
124
|
+
if repo_full_name is not None:
|
|
125
|
+
payload["repoFullName"] = repo_full_name
|
|
126
|
+
if default_branch is not None:
|
|
127
|
+
payload["defaultBranch"] = default_branch
|
|
128
|
+
if config_path is not None:
|
|
129
|
+
payload["configPath"] = config_path
|
|
130
|
+
if clone_token is not None:
|
|
131
|
+
payload["cloneToken"] = clone_token
|
|
132
|
+
if "repoFullName" not in payload:
|
|
133
|
+
raise ValueError("projects_create needs repo_full_name")
|
|
134
|
+
return self._call("POST", f"/api/v1/projects", {}, payload)
|
|
135
|
+
|
|
136
|
+
def projects_create_pipelines(self, id_: str, *, ref: Optional[str] = None, variables: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
137
|
+
"""Pipelines a project (POST /api/v1/projects/{id}/pipelines).
|
|
138
|
+
|
|
139
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
140
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
141
|
+
if ref is not None:
|
|
142
|
+
payload["ref"] = ref
|
|
143
|
+
if variables is not None:
|
|
144
|
+
payload["variables"] = variables
|
|
145
|
+
if "ref" not in payload:
|
|
146
|
+
raise ValueError("projects_create_pipelines needs ref")
|
|
147
|
+
return self._call("POST", f"/api/v1/projects/{_q(id_)}/pipelines", {}, payload)
|
|
148
|
+
|
|
149
|
+
def projects_create_schedules(self, id_: str, *, cron: Optional[str] = None, ref: Optional[str] = None, active: Optional[bool] = None, variables: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
150
|
+
"""Schedules a project (POST /api/v1/projects/{id}/schedules).
|
|
151
|
+
|
|
152
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
153
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
154
|
+
if cron is not None:
|
|
155
|
+
payload["cron"] = cron
|
|
156
|
+
if ref is not None:
|
|
157
|
+
payload["ref"] = ref
|
|
158
|
+
if active is not None:
|
|
159
|
+
payload["active"] = active
|
|
160
|
+
if variables is not None:
|
|
161
|
+
payload["variables"] = variables
|
|
162
|
+
if "cron" not in payload:
|
|
163
|
+
raise ValueError("projects_create_schedules needs cron")
|
|
164
|
+
if "ref" not in payload:
|
|
165
|
+
raise ValueError("projects_create_schedules needs ref")
|
|
166
|
+
return self._call("POST", f"/api/v1/projects/{_q(id_)}/schedules", {}, payload)
|
|
167
|
+
|
|
168
|
+
def projects_create_variables(self, id_: str, *, key: Optional[str] = None, value: Optional[str] = None, backend: Optional[str] = None, masked: Optional[bool] = None, protected: Optional[bool] = None, file_type: Optional[bool] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
169
|
+
"""Variables a project (POST /api/v1/projects/{id}/variables).
|
|
170
|
+
|
|
171
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it).
|
|
172
|
+
backend: one of local, secronna"""
|
|
173
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
174
|
+
if key is not None:
|
|
175
|
+
payload["key"] = key
|
|
176
|
+
if value is not None:
|
|
177
|
+
payload["value"] = value
|
|
178
|
+
if backend is not None:
|
|
179
|
+
payload["backend"] = backend
|
|
180
|
+
if masked is not None:
|
|
181
|
+
payload["masked"] = masked
|
|
182
|
+
if protected is not None:
|
|
183
|
+
payload["protected"] = protected
|
|
184
|
+
if file_type is not None:
|
|
185
|
+
payload["fileType"] = file_type
|
|
186
|
+
if "key" not in payload:
|
|
187
|
+
raise ValueError("projects_create_variables needs key")
|
|
188
|
+
if "value" not in payload:
|
|
189
|
+
raise ValueError("projects_create_variables needs value")
|
|
190
|
+
return self._call("POST", f"/api/v1/projects/{_q(id_)}/variables", {}, payload)
|
|
191
|
+
|
|
192
|
+
def projects_delete(self, id_: str) -> Any:
|
|
193
|
+
"""Delete a project (DELETE /api/v1/projects/{id})."""
|
|
194
|
+
return self._call("DELETE", f"/api/v1/projects/{_q(id_)}", {}, None)
|
|
195
|
+
|
|
196
|
+
def projects_delete_schedules(self, id_: str, sched_id: str) -> Any:
|
|
197
|
+
"""Delete a schedule (DELETE /api/v1/projects/{id}/schedules/{schedId})."""
|
|
198
|
+
return self._call("DELETE", f"/api/v1/projects/{_q(id_)}/schedules/{_q(sched_id)}", {}, None)
|
|
199
|
+
|
|
200
|
+
def projects_delete_variables(self, id_: str, var_id: str) -> Any:
|
|
201
|
+
"""Delete a variable (DELETE /api/v1/projects/{id}/variables/{varId})."""
|
|
202
|
+
return self._call("DELETE", f"/api/v1/projects/{_q(id_)}/variables/{_q(var_id)}", {}, None)
|
|
203
|
+
|
|
204
|
+
def projects_get(self, id_: str) -> Any:
|
|
205
|
+
"""Get a project (GET /api/v1/projects/{id})."""
|
|
206
|
+
return self._call("GET", f"/api/v1/projects/{_q(id_)}", {}, None)
|
|
207
|
+
|
|
208
|
+
def projects_get_pipelines(self, id_: str, iid: str) -> Any:
|
|
209
|
+
"""Get a pipeline (GET /api/v1/projects/{id}/pipelines/{iid})."""
|
|
210
|
+
return self._call("GET", f"/api/v1/projects/{_q(id_)}/pipelines/{_q(iid)}", {}, None)
|
|
211
|
+
|
|
212
|
+
def projects_list(self) -> Any:
|
|
213
|
+
"""List projects (GET /api/v1/projects)."""
|
|
214
|
+
return self._call("GET", f"/api/v1/projects", {}, None)
|
|
215
|
+
|
|
216
|
+
def projects_pipelines(self, id_: str, *, q: Optional[Any] = None, ref: Optional[Any] = None, source: Optional[Any] = None, status: Optional[Any] = None) -> Any:
|
|
217
|
+
"""List pipelines (GET /api/v1/projects/{id}/pipelines)."""
|
|
218
|
+
return self._call("GET", f"/api/v1/projects/{_q(id_)}/pipelines", {"q": q, "ref": ref, "source": source, "status": status}, None)
|
|
219
|
+
|
|
220
|
+
def projects_pipelines_cancel(self, id_: str, iid: str) -> Any:
|
|
221
|
+
"""Cancel a pipeline (POST /api/v1/projects/{id}/pipelines/{iid}/cancel)."""
|
|
222
|
+
return self._call("POST", f"/api/v1/projects/{_q(id_)}/pipelines/{_q(iid)}/cancel", {}, None)
|
|
223
|
+
|
|
224
|
+
def projects_pipelines_retry(self, id_: str, iid: str) -> Any:
|
|
225
|
+
"""Retry a pipeline (POST /api/v1/projects/{id}/pipelines/{iid}/retry)."""
|
|
226
|
+
return self._call("POST", f"/api/v1/projects/{_q(id_)}/pipelines/{_q(iid)}/retry", {}, None)
|
|
227
|
+
|
|
228
|
+
def projects_schedules(self, id_: str) -> Any:
|
|
229
|
+
"""List schedules (GET /api/v1/projects/{id}/schedules)."""
|
|
230
|
+
return self._call("GET", f"/api/v1/projects/{_q(id_)}/schedules", {}, None)
|
|
231
|
+
|
|
232
|
+
def projects_set_config(self, id_: str, *, config_text: Optional[str] = None, commit_message: Optional[str] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
233
|
+
"""Config a project (PUT /api/v1/projects/{id}/config).
|
|
234
|
+
|
|
235
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
236
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
237
|
+
if config_text is not None:
|
|
238
|
+
payload["configText"] = config_text
|
|
239
|
+
if commit_message is not None:
|
|
240
|
+
payload["commitMessage"] = commit_message
|
|
241
|
+
if "configText" not in payload:
|
|
242
|
+
raise ValueError("projects_set_config needs config_text")
|
|
243
|
+
return self._call("PUT", f"/api/v1/projects/{_q(id_)}/config", {}, payload)
|
|
244
|
+
|
|
245
|
+
def projects_update_schedules(self, id_: str, sched_id: str, *, cron: Optional[str] = None, ref: Optional[str] = None, active: Optional[bool] = None, variables: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
246
|
+
"""Update a schedule (PATCH /api/v1/projects/{id}/schedules/{schedId}).
|
|
247
|
+
|
|
248
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
249
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
250
|
+
if cron is not None:
|
|
251
|
+
payload["cron"] = cron
|
|
252
|
+
if ref is not None:
|
|
253
|
+
payload["ref"] = ref
|
|
254
|
+
if active is not None:
|
|
255
|
+
payload["active"] = active
|
|
256
|
+
if variables is not None:
|
|
257
|
+
payload["variables"] = variables
|
|
258
|
+
return self._call("PATCH", f"/api/v1/projects/{_q(id_)}/schedules/{_q(sched_id)}", {}, payload)
|
|
259
|
+
|
|
260
|
+
def projects_update_variables(self, id_: str, var_id: str, *, value: Optional[str] = None, backend: Optional[str] = None, masked: Optional[bool] = None, protected: Optional[bool] = None, file_type: Optional[bool] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
261
|
+
"""Update a variable (PATCH /api/v1/projects/{id}/variables/{varId}).
|
|
262
|
+
|
|
263
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it).
|
|
264
|
+
backend: one of local, secronna"""
|
|
265
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
266
|
+
if value is not None:
|
|
267
|
+
payload["value"] = value
|
|
268
|
+
if backend is not None:
|
|
269
|
+
payload["backend"] = backend
|
|
270
|
+
if masked is not None:
|
|
271
|
+
payload["masked"] = masked
|
|
272
|
+
if protected is not None:
|
|
273
|
+
payload["protected"] = protected
|
|
274
|
+
if file_type is not None:
|
|
275
|
+
payload["fileType"] = file_type
|
|
276
|
+
return self._call("PATCH", f"/api/v1/projects/{_q(id_)}/variables/{_q(var_id)}", {}, payload)
|
|
277
|
+
|
|
278
|
+
def projects_variables(self, id_: str) -> Any:
|
|
279
|
+
"""List variables (GET /api/v1/projects/{id}/variables)."""
|
|
280
|
+
return self._call("GET", f"/api/v1/projects/{_q(id_)}/variables", {}, None)
|
|
281
|
+
|
|
282
|
+
def projects_webhook(self, id_: str) -> Any:
|
|
283
|
+
"""Idempotent webhook (re)create — the settings page "re-create" button. (POST /api/v1/projects/{id}/webhook)."""
|
|
284
|
+
return self._call("POST", f"/api/v1/projects/{_q(id_)}/webhook", {}, None)
|
|
285
|
+
|
|
286
|
+
def public_badges_pipeline_svg(self, badge_token: str) -> Any:
|
|
287
|
+
"""List pipeline.svg (GET /api/v1/public/badges/{badgeToken}/pipeline.svg)."""
|
|
288
|
+
return self._call("GET", f"/api/v1/public/badges/{_q(badge_token)}/pipeline.svg", {}, None)
|
|
289
|
+
|
|
290
|
+
def runners_list(self) -> Any:
|
|
291
|
+
"""List runners (GET /api/v1/runners)."""
|
|
292
|
+
return self._call("GET", f"/api/v1/runners", {}, None)
|
|
293
|
+
|
|
294
|
+
def runners_pause(self, id_: str) -> Any:
|
|
295
|
+
"""Pause a runner (POST /api/v1/runners/{id}/pause)."""
|
|
296
|
+
return self._call("POST", f"/api/v1/runners/{_q(id_)}/pause", {}, None)
|
|
297
|
+
|
|
298
|
+
def runners_resume(self, id_: str) -> Any:
|
|
299
|
+
"""Resume a runner (POST /api/v1/runners/{id}/resume)."""
|
|
300
|
+
return self._call("POST", f"/api/v1/runners/{_q(id_)}/resume", {}, None)
|
|
301
|
+
|
|
302
|
+
def schedules_delete(self, id_: str) -> Any:
|
|
303
|
+
"""Delete a schedule (DELETE /api/v1/schedules/{id})."""
|
|
304
|
+
return self._call("DELETE", f"/api/v1/schedules/{_q(id_)}", {}, None)
|
|
305
|
+
|
|
306
|
+
def schedules_list(self) -> Any:
|
|
307
|
+
"""List schedules (GET /api/v1/schedules)."""
|
|
308
|
+
return self._call("GET", f"/api/v1/schedules", {}, None)
|
|
309
|
+
|
|
310
|
+
def schedules_update(self, id_: str, *, cron: Optional[str] = None, ref: Optional[str] = None, active: Optional[bool] = None, variables: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
311
|
+
"""Update a schedule (PATCH /api/v1/schedules/{id}).
|
|
312
|
+
|
|
313
|
+
Body fields are keyword arguments; `json_body=` passes the whole body (fields override it)."""
|
|
314
|
+
payload: Dict[str, Any] = dict(json_body or {})
|
|
315
|
+
if cron is not None:
|
|
316
|
+
payload["cron"] = cron
|
|
317
|
+
if ref is not None:
|
|
318
|
+
payload["ref"] = ref
|
|
319
|
+
if active is not None:
|
|
320
|
+
payload["active"] = active
|
|
321
|
+
if variables is not None:
|
|
322
|
+
payload["variables"] = variables
|
|
323
|
+
return self._call("PATCH", f"/api/v1/schedules/{_q(id_)}", {}, payload)
|
|
324
|
+
|
|
325
|
+
def usage_list(self) -> Any:
|
|
326
|
+
"""List usage (GET /api/v1/usage)."""
|
|
327
|
+
return self._call("GET", f"/api/v1/usage", {}, None)
|
|
328
|
+
|
|
329
|
+
def projects_pipelines_2(self, *args: Any, **kwargs: Any) -> Any:
|
|
330
|
+
"""Deprecated: the old name of ``projects_get_pipelines`` (GET /api/v1/projects/{id}/pipelines/{iid})."""
|
|
331
|
+
return self.projects_get_pipelines(*args, **kwargs)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _q(value: Any) -> str:
|
|
335
|
+
from urllib.parse import quote
|
|
336
|
+
|
|
337
|
+
return quote(str(value), safe="")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Depllo client — the Python sister of ``@forjio/depllo`` (JS).
|
|
2
|
+
|
|
3
|
+
Auth = Bearer token: a workspace API key (``sk_live_…``, Dashboard → API
|
|
4
|
+
Keys) or a Huudis access token. Pass ``token=`` or set ``DEPLLO_TOKEN``.
|
|
5
|
+
|
|
6
|
+
Like the JS SDK, every call returns the Forjio envelope
|
|
7
|
+
``{"data", "error", "meta"}`` as a dict (read ``["data"]``); a route that
|
|
8
|
+
answers with bytes (a job artifact, a badge SVG) returns
|
|
9
|
+
``{"data": {"data": bytes, "contentType", "filename"}, "error": None}``. A
|
|
10
|
+
failed HTTP response raises :class:`DeplloError` with the envelope's
|
|
11
|
+
``error.code``.
|
|
12
|
+
Mutating calls carry an ``Idempotency-Key``.
|
|
13
|
+
|
|
14
|
+
Every feature route is a method on ``client.api`` (generated from the API
|
|
15
|
+
spec — ``api_generated.py``)::
|
|
16
|
+
|
|
17
|
+
client = DeplloClient(token=os.environ["DEPLLO_TOKEN"])
|
|
18
|
+
projects = client.api.projects_list()["data"]
|
|
19
|
+
client.api.projects_create_pipelines(projects[0]["id"], ref="main")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import random
|
|
27
|
+
import re
|
|
28
|
+
import string
|
|
29
|
+
import time
|
|
30
|
+
from urllib.parse import unquote
|
|
31
|
+
from typing import Any, Dict, Optional
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
from .api_generated import GeneratedApi
|
|
36
|
+
from .errors import DeplloError
|
|
37
|
+
|
|
38
|
+
DEFAULT_BASE_URL = "https://depllo.forjio.com/api/v1"
|
|
39
|
+
_PREFIX = "/api/v1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _idempotency_key() -> str:
|
|
43
|
+
tail = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
|
44
|
+
return f"sdk_{int(time.time() * 1000)}_{tail}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DeplloClient:
|
|
48
|
+
"""Depllo API client.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
token:
|
|
53
|
+
Bearer token — an ``sk_live_…`` API key or a Huudis access token.
|
|
54
|
+
Defaults to the ``DEPLLO_TOKEN`` environment variable.
|
|
55
|
+
base_url:
|
|
56
|
+
API base, default ``https://depllo.forjio.com/api/v1``.
|
|
57
|
+
timeout:
|
|
58
|
+
Per-request timeout in seconds (default 30).
|
|
59
|
+
http_client:
|
|
60
|
+
An ``httpx.Client`` to send requests with (tests, proxies, custom
|
|
61
|
+
transports). The client's own ``base_url`` is not used.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
token: Optional[str] = None,
|
|
68
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
http_client: Optional[httpx.Client] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._token = token if token is not None else os.environ.get("DEPLLO_TOKEN")
|
|
73
|
+
self._base_url = base_url.rstrip("/")
|
|
74
|
+
self._timeout = timeout
|
|
75
|
+
self._http = http_client
|
|
76
|
+
# Every feature route, one method each (generated from the API spec).
|
|
77
|
+
self.api = GeneratedApi(self)
|
|
78
|
+
|
|
79
|
+
def _apigen_request(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
path: str,
|
|
83
|
+
*,
|
|
84
|
+
query: Optional[Dict[str, Any]] = None,
|
|
85
|
+
body: Optional[Dict[str, Any]] = None,
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
"""The call behind ``client.api.*``: the same bearer token, idempotency key
|
|
88
|
+
and envelope as every other call. The spec's paths carry the /api/v1 prefix
|
|
89
|
+
the base URL already ends in."""
|
|
90
|
+
rel = path[len(_PREFIX):] if path.startswith(_PREFIX + "/") else path
|
|
91
|
+
return self.request(method, rel, query=query or None, body=body)
|
|
92
|
+
|
|
93
|
+
def request(
|
|
94
|
+
self,
|
|
95
|
+
method: str,
|
|
96
|
+
path: str,
|
|
97
|
+
*,
|
|
98
|
+
query: Optional[Dict[str, Any]] = None,
|
|
99
|
+
body: Any = None,
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""Send one request; return the envelope dict or raise :class:`DeplloError`."""
|
|
102
|
+
url = self._base_url + (path if path.startswith("/") else f"/{path}")
|
|
103
|
+
params = {k: v if isinstance(v, str) else json.dumps(v) for k, v in (query or {}).items() if v is not None}
|
|
104
|
+
headers = {"Accept": "application/json"}
|
|
105
|
+
if self._token:
|
|
106
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
107
|
+
if body is not None:
|
|
108
|
+
headers["Content-Type"] = "application/json"
|
|
109
|
+
if method.upper() not in ("GET", "HEAD"):
|
|
110
|
+
headers["Idempotency-Key"] = _idempotency_key()
|
|
111
|
+
content = json.dumps(body).encode() if body is not None else None
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
if self._http is not None:
|
|
115
|
+
resp = self._http.request(
|
|
116
|
+
method, url, params=params, headers=headers, content=content, timeout=self._timeout
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
resp = httpx.request(
|
|
120
|
+
method, url, params=params, headers=headers, content=content, timeout=self._timeout
|
|
121
|
+
)
|
|
122
|
+
except httpx.TimeoutException as e:
|
|
123
|
+
raise DeplloError(f"request timed out: {e}", "TIMEOUT", 0) from e
|
|
124
|
+
except httpx.HTTPError as e:
|
|
125
|
+
raise DeplloError(f"request failed: {e}", "NETWORK_ERROR", 0) from e
|
|
126
|
+
|
|
127
|
+
content_type = resp.headers.get("content-type", "")
|
|
128
|
+
if resp.status_code < 400 and content_type and not re.search(r"json|text/(html|plain)", content_type, re.I):
|
|
129
|
+
# a file (an artifact, a badge SVG): an HTML or plain-text page at an API path
|
|
130
|
+
# is a wrong base URL or a proxy, and stays an error below
|
|
131
|
+
disposition = resp.headers.get("content-disposition", "")
|
|
132
|
+
star = re.search(r"filename\*=UTF-8''([^;]+)", disposition, re.I)
|
|
133
|
+
plain = re.search(r'filename="?([^";]+)"?', disposition, re.I)
|
|
134
|
+
return {
|
|
135
|
+
"data": {
|
|
136
|
+
"data": resp.content,
|
|
137
|
+
"contentType": content_type,
|
|
138
|
+
"filename": unquote(star.group(1)) if star else (plain.group(1) if plain else None),
|
|
139
|
+
},
|
|
140
|
+
"error": None,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
parsed: Any = None
|
|
144
|
+
if resp.content:
|
|
145
|
+
try:
|
|
146
|
+
parsed = resp.json()
|
|
147
|
+
except ValueError:
|
|
148
|
+
parsed = None
|
|
149
|
+
envelope = parsed if isinstance(parsed, dict) else None
|
|
150
|
+
error = envelope.get("error") if envelope else None
|
|
151
|
+
meta = envelope.get("meta") if envelope else None
|
|
152
|
+
request_id = meta.get("requestId") if isinstance(meta, dict) else None
|
|
153
|
+
|
|
154
|
+
if resp.status_code >= 400:
|
|
155
|
+
raise DeplloError(
|
|
156
|
+
(error or {}).get("message") or f"HTTP {resp.status_code} {resp.reason_phrase}".strip(),
|
|
157
|
+
(error or {}).get("code"),
|
|
158
|
+
resp.status_code,
|
|
159
|
+
request_id,
|
|
160
|
+
)
|
|
161
|
+
if envelope is None:
|
|
162
|
+
raise DeplloError("Empty or non-JSON response from server.", "INVALID_RESPONSE", resp.status_code)
|
|
163
|
+
return envelope
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["DeplloClient", "DeplloError", "DEFAULT_BASE_URL"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Error class for the Depllo SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DeplloError(Exception):
|
|
9
|
+
"""Raised when a Depllo API call fails.
|
|
10
|
+
|
|
11
|
+
Attributes
|
|
12
|
+
----------
|
|
13
|
+
message:
|
|
14
|
+
Human-readable description (the envelope's ``error.message``).
|
|
15
|
+
code:
|
|
16
|
+
Machine-readable code from the API envelope (``NOT_FOUND``,
|
|
17
|
+
``VALIDATION_ERROR``, ``AUTH_REQUIRED``, ``INVALID_TOKEN``, ...), or
|
|
18
|
+
``NETWORK_ERROR`` / ``TIMEOUT`` / ``INVALID_RESPONSE`` for SDK-side
|
|
19
|
+
failures. ``None`` when the server sent no envelope.
|
|
20
|
+
status:
|
|
21
|
+
HTTP status code (0 for transport-level failures).
|
|
22
|
+
request_id:
|
|
23
|
+
The ``meta.requestId`` echoed by the API, when present.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
code: Optional[str] = None,
|
|
30
|
+
status: Optional[int] = None,
|
|
31
|
+
request_id: Optional[str] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
self.code = code
|
|
36
|
+
self.status = status
|
|
37
|
+
self.request_id = request_id
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return (
|
|
41
|
+
f"DeplloError(code={self.code!r}, status={self.status!r}, "
|
|
42
|
+
f"message={self.message!r}, request_id={self.request_id!r})"
|
|
43
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "forjio-depllo"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Depllo SDK — Python client for the Depllo CI/CD REST API. Sister to @forjio/depllo (JS)."
|
|
5
|
+
authors = [{ name = "Forjio", email = "support@forjio.com" }]
|
|
6
|
+
license = { text = "Proprietary" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"httpx>=0.27.0",
|
|
11
|
+
]
|
|
12
|
+
keywords = ["depllo", "forjio", "ci", "cd", "pipelines", "sdk"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8.0.0"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://depllo.forjio.com/docs/sdk"
|
|
19
|
+
Repository = "https://github.com/hachimi-cat/saas-depllo"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["forjio_depllo"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""client.api: every feature route, one method each, generated from the API spec
|
|
2
|
+
(scripts/apigen.sh). Calls carry the bearer token and idempotency key like every
|
|
3
|
+
other request, and return the envelope like the JS SDK."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
from urllib.parse import parse_qs, urlsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from forjio_depllo import DeplloClient
|
|
15
|
+
from forjio_depllo.api_generated import GeneratedApi
|
|
16
|
+
|
|
17
|
+
ENVELOPE = {"data": {"ok": True}, "error": None, "meta": {"requestId": "r"}}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client(token: str = "sk_live_test", base_url: str = "https://depllo.test/api/v1") -> Tuple[DeplloClient, List[httpx.Request]]:
|
|
21
|
+
seen: List[httpx.Request] = []
|
|
22
|
+
|
|
23
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
24
|
+
seen.append(request)
|
|
25
|
+
return httpx.Response(200, json=ENVELOPE)
|
|
26
|
+
|
|
27
|
+
http = httpx.Client(transport=httpx.MockTransport(handler))
|
|
28
|
+
return DeplloClient(token=token, base_url=base_url, http_client=http), seen
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_is_mounted_on_the_client() -> None:
|
|
32
|
+
client, _ = _client()
|
|
33
|
+
assert isinstance(client.api, GeneratedApi)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_create_sends_the_fields_depllo_validates_with_the_bearer_key() -> None:
|
|
37
|
+
client, seen = _client()
|
|
38
|
+
out = client.api.projects_create_variables("proj_1", key="API_URL", value="x", masked=True)
|
|
39
|
+
assert out == ENVELOPE
|
|
40
|
+
request = seen[0]
|
|
41
|
+
assert (request.method, request.url.path) == ("POST", "/api/v1/projects/proj_1/variables")
|
|
42
|
+
assert json.loads(request.content) == {"key": "API_URL", "value": "x", "masked": True}
|
|
43
|
+
assert request.headers["authorization"] == "Bearer sk_live_test"
|
|
44
|
+
assert request.headers["content-type"] == "application/json"
|
|
45
|
+
assert request.headers["idempotency-key"].startswith("sdk_")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_path_parameters_are_escaped_and_query_fields_go_in_the_query() -> None:
|
|
49
|
+
client, seen = _client(base_url="http://localhost:4200/api/v1/")
|
|
50
|
+
client.api.projects_delete_schedules("proj 1", "sch/2")
|
|
51
|
+
client.api.jobs_log("job_1", from_=5)
|
|
52
|
+
assert seen[0].method == "DELETE"
|
|
53
|
+
assert seen[0].url.raw_path.decode() == "/api/v1/projects/proj%201/schedules/sch%2F2"
|
|
54
|
+
assert seen[1].url.path == "/api/v1/jobs/job_1/log"
|
|
55
|
+
assert parse_qs(urlsplit(str(seen[1].url)).query) == {"from": ["5"]}
|
|
56
|
+
assert seen[1].method == "GET"
|
|
57
|
+
assert "idempotency-key" not in seen[1].headers
|
|
58
|
+
assert seen[1].content == b""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_json_body_passes_the_whole_body_and_fields_override_it() -> None:
|
|
62
|
+
client, seen = _client()
|
|
63
|
+
client.api.api_keys_create(json_body={"name": "from-body"}, name="catent")
|
|
64
|
+
assert json.loads(seen[0].content) == {"name": "catent"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_a_required_field_is_asked_for() -> None:
|
|
68
|
+
client, _ = _client()
|
|
69
|
+
with pytest.raises(ValueError, match="needs"):
|
|
70
|
+
client.api.api_keys_create()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_every_feature_route_has_a_method() -> None:
|
|
74
|
+
client, _ = _client()
|
|
75
|
+
methods = [n for n in dir(client.api) if not n.startswith("_")]
|
|
76
|
+
assert len(methods) >= 45
|
|
77
|
+
for name in ("projects_list", "projects_create_pipelines", "pipelines_cancel", "jobs_log", "runners_list", "api_keys_list"):
|
|
78
|
+
assert name in methods
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _replying(response: httpx.Response) -> DeplloClient:
|
|
82
|
+
http = httpx.Client(transport=httpx.MockTransport(lambda request: response))
|
|
83
|
+
return DeplloClient(token="sk_live_test", base_url="https://depllo.test/api/v1", http_client=http)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_file_route_returns_the_bytes_in_the_envelope() -> None:
|
|
87
|
+
client = _replying(
|
|
88
|
+
httpx.Response(200, content=b"PK\x03\x04", headers={"content-type": "application/zip", "content-disposition": 'attachment; filename="dist.zip"'})
|
|
89
|
+
)
|
|
90
|
+
out = client.api.jobs_artifacts_download("job_1", "art_1")
|
|
91
|
+
assert out == {"data": {"data": b"PK\x03\x04", "contentType": "application/zip", "filename": "dist.zip"}, "error": None}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_an_html_page_at_an_api_path_is_still_an_error() -> None:
|
|
95
|
+
from forjio_depllo import DeplloError
|
|
96
|
+
|
|
97
|
+
client = _replying(httpx.Response(200, content=b"<html></html>", headers={"content-type": "text/html"}))
|
|
98
|
+
with pytest.raises(DeplloError, match="non-JSON"):
|
|
99
|
+
client.api.projects_list()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""DeplloClient: token, base URL, envelope and errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from forjio_depllo import DeplloClient, DeplloError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _http(status: int, body: object = None, *, text: str | None = None, seen: List[httpx.Request] | None = None) -> httpx.Client:
|
|
14
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
15
|
+
if seen is not None:
|
|
16
|
+
seen.append(request)
|
|
17
|
+
if text is not None:
|
|
18
|
+
return httpx.Response(status, text=text)
|
|
19
|
+
return httpx.Response(status, json=body)
|
|
20
|
+
|
|
21
|
+
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_token_defaults_to_the_depllo_token_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
25
|
+
monkeypatch.setenv("DEPLLO_TOKEN", "sk_live_env")
|
|
26
|
+
seen: List[httpx.Request] = []
|
|
27
|
+
client = DeplloClient(http_client=_http(200, {"data": [], "error": None}, seen=seen))
|
|
28
|
+
client.request("GET", "/projects")
|
|
29
|
+
assert seen[0].headers["authorization"] == "Bearer sk_live_env"
|
|
30
|
+
assert str(seen[0].url) == "https://depllo.forjio.com/api/v1/projects"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_no_token_sends_no_authorization_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
monkeypatch.delenv("DEPLLO_TOKEN", raising=False)
|
|
35
|
+
seen: List[httpx.Request] = []
|
|
36
|
+
DeplloClient(http_client=_http(200, {"data": None, "error": None}, seen=seen)).request("GET", "/usage")
|
|
37
|
+
assert "authorization" not in seen[0].headers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_returns_the_envelope_like_the_js_sdk() -> None:
|
|
41
|
+
env = {"data": {"id": "proj_1"}, "error": None, "meta": {"requestId": "req_1"}}
|
|
42
|
+
client = DeplloClient(token="t", http_client=_http(200, env))
|
|
43
|
+
assert client.api.projects_get("proj_1") == env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_error_envelope_raises_depllo_error() -> None:
|
|
47
|
+
body = {"data": None, "error": {"code": "INVALID_TOKEN", "message": "Invalid API key"}, "meta": {"requestId": "req_9"}}
|
|
48
|
+
client = DeplloClient(token="sk_live_revoked", http_client=_http(401, body))
|
|
49
|
+
with pytest.raises(DeplloError) as exc:
|
|
50
|
+
client.api.projects_list()
|
|
51
|
+
assert (exc.value.code, exc.value.status, exc.value.message, exc.value.request_id) == (
|
|
52
|
+
"INVALID_TOKEN",
|
|
53
|
+
401,
|
|
54
|
+
"Invalid API key",
|
|
55
|
+
"req_9",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_non_json_error_reports_the_status() -> None:
|
|
60
|
+
client = DeplloClient(token="t", http_client=_http(502, text="<html>bad gateway</html>"))
|
|
61
|
+
with pytest.raises(DeplloError) as exc:
|
|
62
|
+
client.api.usage_list()
|
|
63
|
+
assert exc.value.status == 502
|
|
64
|
+
assert exc.value.code is None
|
|
65
|
+
assert "HTTP 502" in exc.value.message
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_non_json_success_is_an_invalid_response() -> None:
|
|
69
|
+
client = DeplloClient(token="t", http_client=_http(200, text="ok"))
|
|
70
|
+
with pytest.raises(DeplloError) as exc:
|
|
71
|
+
client.request("GET", "/usage")
|
|
72
|
+
assert exc.value.code == "INVALID_RESPONSE"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_transport_failure_is_a_network_error() -> None:
|
|
76
|
+
def boom(request: httpx.Request) -> httpx.Response:
|
|
77
|
+
raise httpx.ConnectError("refused", request=request)
|
|
78
|
+
|
|
79
|
+
client = DeplloClient(token="t", http_client=httpx.Client(transport=httpx.MockTransport(boom)))
|
|
80
|
+
with pytest.raises(DeplloError) as exc:
|
|
81
|
+
client.api.runners_list()
|
|
82
|
+
assert (exc.value.code, exc.value.status) == ("NETWORK_ERROR", 0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_query_values_are_sent_as_the_js_sdk_sends_them() -> None:
|
|
86
|
+
seen: List[httpx.Request] = []
|
|
87
|
+
client = DeplloClient(token="t", http_client=_http(200, {"data": [], "error": None}, seen=seen))
|
|
88
|
+
client.request("GET", "/audit", query={"limit": 5, "flag": True, "action": "x", "skip": None})
|
|
89
|
+
assert dict(seen[0].url.params) == {"limit": "5", "flag": "true", "action": "x"}
|