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.
@@ -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,5 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+ - A route read by id next to its list is named `get` + the list's name: `client.api.projects_get_pipelines` (was `client.api.projects_pipelines_2`). Each old name stays as a deprecated alias.
5
+
@@ -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,6 @@
1
+ """Depllo Python SDK — client for the Depllo CI/CD REST API."""
2
+ from .client import DeplloClient
3
+ from .errors import DeplloError
4
+
5
+ __all__ = ["DeplloClient", "DeplloError"]
6
+ __version__ = "0.2.0"
@@ -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"}