securedeploy 0.1.0__py3-none-any.whl

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,3 @@
1
+ from securedeploy.client import SecureDeploy
2
+
3
+ __all__ = ["SecureDeploy"]
securedeploy/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import json
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ import click
10
+
11
+ from securedeploy import SecureDeploy
12
+
13
+ _WORKSPACE_SLUG_OPTION_HELP = "Workspace slug (default: config)."
14
+ _APPLICATION_SLUG_OPTION_HELP = "Application slug (default: config)."
15
+ _URL_PATH_OPTION_HELP = (
16
+ "Path under the app host URL (after the domain). "
17
+ "With --file: full path including file name (extension must match the file). "
18
+ "With --path: prefix added before each file's path relative to that directory."
19
+ )
20
+
21
+
22
+ def handle_errors(f: Callable[..., Any]) -> Callable[..., Any]:
23
+ @functools.wraps(f)
24
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
25
+ try:
26
+ return f(*args, **kwargs)
27
+ except click.UsageError:
28
+ raise
29
+ except Exception as e:
30
+ raise click.ClickException(str(e)) from e
31
+
32
+ return wrapper
33
+
34
+
35
+ def _echo_json(data: object) -> None:
36
+ if data is None:
37
+ return
38
+ click.echo(json.dumps(data, indent=2))
39
+
40
+
41
+ @click.group()
42
+ @click.option(
43
+ "--dev",
44
+ is_flag=True,
45
+ help="Use local dev URLs.",
46
+ )
47
+ @click.pass_context
48
+ def cli(ctx: click.Context, dev: bool) -> None:
49
+ ctx.obj = SecureDeploy(env="development" if dev else "production")
50
+
51
+
52
+ @cli.group()
53
+ def auth() -> None:
54
+ """Authenticate with SecureDeploy."""
55
+
56
+
57
+ @auth.command("login")
58
+ @click.pass_context
59
+ @handle_errors
60
+ def auth_login(ctx: click.Context) -> None:
61
+ ctx.obj.auth.login(
62
+ verification_callback=lambda uri: click.echo(
63
+ f"Please go to this URL to authorize: {uri}"
64
+ )
65
+ )
66
+ click.echo("Login successful")
67
+
68
+
69
+ @auth.command("refresh-token")
70
+ @click.pass_context
71
+ @handle_errors
72
+ def auth_refresh_token(ctx: click.Context) -> None:
73
+ """Exchange the stored refresh token for new tokens."""
74
+ ctx.obj.auth.refresh_token()
75
+ _echo_json(ctx.obj.whoami())
76
+
77
+
78
+ @auth.command("inspect")
79
+ @click.pass_context
80
+ @handle_errors
81
+ def auth_inspect(ctx: click.Context) -> None:
82
+ """Print stored access and refresh tokens from local config as JSON."""
83
+ _echo_json(ctx.obj.auth.inspect())
84
+
85
+
86
+ @cli.command("whoami")
87
+ @click.pass_context
88
+ @handle_errors
89
+ def whoami_cmd(ctx: click.Context) -> None:
90
+ _echo_json(ctx.obj.whoami())
91
+
92
+
93
+ @cli.group()
94
+ def workspaces() -> None:
95
+ """Manage workspaces."""
96
+
97
+
98
+ @workspaces.command("list")
99
+ @click.pass_context
100
+ @handle_errors
101
+ def workspaces_list(ctx: click.Context) -> None:
102
+ _echo_json(ctx.obj.workspaces.list())
103
+
104
+
105
+ @workspaces.command("set-default")
106
+ @click.argument("slug", required=False)
107
+ @click.pass_context
108
+ @handle_errors
109
+ def workspaces_set_default(ctx: click.Context, slug: Optional[str]) -> None:
110
+ if not slug:
111
+ workspaces = ctx.obj.workspaces.list()
112
+ if not workspaces:
113
+ raise click.UsageError("No workspaces found; create one first.")
114
+ slug = str(workspaces[0].get("slug") or "")
115
+ if not slug:
116
+ raise click.ClickException(
117
+ "First workspace in list has no slug; pass slug explicitly."
118
+ )
119
+ ctx.obj.workspaces.set_default(slug)
120
+ click.echo(f"Default workspace set to {slug!r}")
121
+
122
+
123
+ @workspaces.command("get")
124
+ @click.argument("slug", required=False)
125
+ @click.pass_context
126
+ @handle_errors
127
+ def workspaces_get(ctx: click.Context, slug: Optional[str]) -> None:
128
+ if slug:
129
+ ws = ctx.obj.workspaces.get(slug)
130
+ else:
131
+ ws = ctx.obj.workspaces.get_default()
132
+ _echo_json(ws.data)
133
+
134
+
135
+ @workspaces.command("create")
136
+ @click.option("--name", default=None, help="Workspace display name.")
137
+ @click.option("--slug", required=True, help="Workspace slug.")
138
+ @click.pass_context
139
+ @handle_errors
140
+ def workspaces_create(ctx: click.Context, name: Optional[str], slug: str) -> None:
141
+ ws = ctx.obj.workspaces.create(name=name, slug=slug)
142
+ _echo_json(ws.data)
143
+
144
+
145
+ @cli.group()
146
+ def applications() -> None:
147
+ """Manage applications."""
148
+
149
+
150
+ @applications.command("list")
151
+ @click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
152
+ @click.pass_context
153
+ @handle_errors
154
+ def applications_list(ctx: click.Context, workspace: Optional[str]) -> None:
155
+ _echo_json(ctx.obj.applications.list(workspace=workspace))
156
+
157
+
158
+ @applications.command("get")
159
+ @click.argument("slug", required=False)
160
+ @click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
161
+ @click.pass_context
162
+ @handle_errors
163
+ def applications_get(
164
+ ctx: click.Context, slug: Optional[str], workspace: Optional[str]
165
+ ) -> None:
166
+ app = ctx.obj.applications.get(slug, workspace=workspace)
167
+ _echo_json(app.data)
168
+
169
+
170
+ @applications.command("create")
171
+ @click.option("--name", default=None, help="Application display name.")
172
+ @click.option("--slug", required=True, help="Application slug.")
173
+ @click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
174
+ @click.pass_context
175
+ @handle_errors
176
+ def applications_create(
177
+ ctx: click.Context,
178
+ name: Optional[str],
179
+ slug: str,
180
+ workspace: Optional[str],
181
+ ) -> None:
182
+ _echo_json(ctx.obj.applications.create(name=name, slug=slug, workspace=workspace))
183
+
184
+
185
+ @applications.command("set-default")
186
+ @click.argument("slug")
187
+ @click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
188
+ @click.pass_context
189
+ @handle_errors
190
+ def applications_set_default(
191
+ ctx: click.Context, slug: str, workspace: Optional[str]
192
+ ) -> None:
193
+ ctx.obj.applications.set_default(slug, workspace=workspace)
194
+ click.echo(f"Default application set to {slug!r}")
195
+
196
+
197
+ @cli.group()
198
+ def defaults() -> None:
199
+ """Show configured defaults."""
200
+
201
+
202
+ @defaults.command("list")
203
+ @click.pass_context
204
+ @handle_errors
205
+ def defaults_list(ctx: click.Context) -> None:
206
+ _echo_json(ctx.obj.defaults.list())
207
+
208
+
209
+ def _upload_cli_url_path_for_file(file: Path, url_path: str) -> str:
210
+ up = url_path.strip()
211
+ if not up:
212
+ raise click.UsageError("--url-path must not be empty.")
213
+ normalized = up.replace("\\", "/").lstrip("/")
214
+ if normalized.startswith(".."):
215
+ raise click.UsageError("--url-path must not start with '..'.")
216
+ if "/.." in normalized:
217
+ raise click.UsageError(
218
+ "--url-path must not contain '/..' (parent directory segments)."
219
+ )
220
+ if normalized.endswith("/"):
221
+ raise click.UsageError(
222
+ "--url-path must not be a directory path (remove trailing '/')."
223
+ )
224
+ base = Path(normalized).name
225
+ if not base or base in (".", ".."):
226
+ raise click.UsageError("--url-path must include a file name.")
227
+ ext_file = file.suffix.lower()
228
+ ext_url = Path(normalized).suffix.lower()
229
+ if ext_file != ext_url:
230
+ raise click.UsageError(
231
+ f"--url-path extension must match the file: expected {ext_file!r}, "
232
+ f"got {ext_url!r}."
233
+ )
234
+ return normalized
235
+
236
+
237
+ @cli.command("upload")
238
+ @click.option("--file", type=click.Path(path_type=Path), default=None)
239
+ @click.option("--path", type=click.Path(path_type=Path), default=None)
240
+ @click.option("--url-path", default=None, help=_URL_PATH_OPTION_HELP)
241
+ @click.option("--application", default=None, help=_APPLICATION_SLUG_OPTION_HELP)
242
+ @click.option("--workspace", default=None, help=_WORKSPACE_SLUG_OPTION_HELP)
243
+ @click.pass_context
244
+ @handle_errors
245
+ def upload_cmd(
246
+ ctx: click.Context,
247
+ file: Optional[Path],
248
+ path: Optional[Path],
249
+ url_path: Optional[str],
250
+ application: Optional[str],
251
+ workspace: Optional[str],
252
+ ) -> None:
253
+ if (file is None) == (path is None):
254
+ raise click.UsageError("Specify exactly one of --file or --path.")
255
+ if file is not None:
256
+ url_p = _upload_cli_url_path_for_file(file, url_path) if url_path else None
257
+ _echo_json(
258
+ ctx.obj.upload.file(
259
+ file, application=application, workspace=workspace, url_path=url_p
260
+ )
261
+ )
262
+ else:
263
+ _echo_json(
264
+ ctx.obj.upload.path(
265
+ path,
266
+ application=application,
267
+ workspace=workspace,
268
+ url_path=url_path,
269
+ )
270
+ )
271
+
272
+
273
+ if __name__ == "__main__":
274
+ cli()
securedeploy/client.py ADDED
@@ -0,0 +1,509 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import webbrowser
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from json import dumps
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Literal, Optional, Union
10
+
11
+ import httpx
12
+ import tomli_w
13
+ from httpx import ConnectError, TimeoutException
14
+
15
+ from securedeploy.config import Config
16
+ from securedeploy.oauth import OAuth
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Max size per file for uploads (directory walks skip oversize files; single-file upload raises).
21
+ _MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024
22
+
23
+ # Basenames compared case-insensitively; common secret/credential artifacts in repos.
24
+ _UPLOAD_IGNORE_FILENAMES: frozenset[str] = frozenset(
25
+ {
26
+ ".git-credentials",
27
+ ".htpasswd",
28
+ ".lesshst",
29
+ ".mysql_history",
30
+ ".netrc",
31
+ ".npmrc",
32
+ ".pgpass",
33
+ ".psql_history",
34
+ ".pypirc",
35
+ ".rediscli_history",
36
+ ".sqlite_history",
37
+ "credentials.json",
38
+ "google-services.json",
39
+ "googleservice-info.plist",
40
+ "id_dsa",
41
+ "id_ecdsa",
42
+ "id_ed25519",
43
+ "id_rsa",
44
+ "local.settings.json", # Azure Functions secrets
45
+ "pscredentials.xml",
46
+ "secrets.json",
47
+ "webhook.secret",
48
+ }
49
+ )
50
+
51
+ # Suffixes (lowercase, leading dot) often used for keys, keystores, VPN, etc.
52
+ _UPLOAD_IGNORE_EXTENSIONS: frozenset[str] = frozenset(
53
+ {
54
+ ".der",
55
+ ".jks",
56
+ ".kdbx",
57
+ ".key",
58
+ ".keystore",
59
+ ".ovpn",
60
+ ".p12",
61
+ ".p8",
62
+ ".pem",
63
+ ".pfx",
64
+ }
65
+ )
66
+
67
+
68
+ def _upload_ignore_reason(file_path: Path) -> Optional[str]:
69
+ """If file_path should not be uploaded from a directory walk, return why; else None."""
70
+ name_lower = file_path.name.lower()
71
+ if name_lower == ".env" or name_lower.startswith(".env."):
72
+ return "sensitive env file (.env*)"
73
+ if name_lower == "credentials" and file_path.parent.name.lower() == ".aws":
74
+ return "AWS credentials file"
75
+ if name_lower in _UPLOAD_IGNORE_FILENAMES:
76
+ return "sensitive filename"
77
+ if file_path.suffix.lower() in _UPLOAD_IGNORE_EXTENSIONS:
78
+ return f"sensitive extension {file_path.suffix!r}"
79
+ return None
80
+
81
+
82
+ @contextmanager
83
+ def _upstream_request_guard() -> Iterator[None]:
84
+ try:
85
+ yield
86
+ except ConnectError as e:
87
+ raise ConnectError(
88
+ "Upstream API is not reachable; \n"
89
+ "Please check your connection and try again. \n\n"
90
+ "If the problem persists, please contact support at support@securedeploy.org. \n"
91
+ "Please include the following information in your support request: \n"
92
+ f"Error details: {e}."
93
+ ) from e
94
+ except TimeoutException as e:
95
+ raise TimeoutException(
96
+ "Upstream API has timed out; \n"
97
+ "Please check your connection and try again. \n\n"
98
+ "If the problem persists, please contact support at support@securedeploy.org. \n"
99
+ "Please include the following information in your support request: \n"
100
+ f"Error details: {e}."
101
+ ) from e
102
+
103
+
104
+ def _handle_api_error_response(
105
+ r: httpx.Response,
106
+ method: str,
107
+ path: str,
108
+ auth: _Auth,
109
+ retry: Optional[Callable[[], Any]],
110
+ ) -> Any:
111
+ ct = r.headers.get("content-type", "").lower()
112
+ if r.content and "json" in ct:
113
+ try:
114
+ detail = dumps(r.json(), indent=2)
115
+ except ValueError:
116
+ detail = r.text
117
+ else:
118
+ detail = r.text
119
+ msg = f"{method} {path} failed ({r.status_code}):\n{detail}"
120
+ if r.status_code == 404:
121
+ raise ResourceNotFoundError(msg)
122
+ if r.status_code == 401:
123
+ if retry is None:
124
+ raise RuntimeError(msg)
125
+ logger.info("Refreshing access token")
126
+ auth.refresh_token()
127
+ return retry()
128
+ raise RuntimeError(msg)
129
+
130
+
131
+ class ResourceNotFoundError(RuntimeError):
132
+ """Raised when the API returns HTTP 404."""
133
+
134
+
135
+ class SecureDeploy:
136
+ """Client for the securedeploy.org API; mirrors CLI command groups."""
137
+
138
+ def __init__(
139
+ self, env: Literal["development", "production"] = "production"
140
+ ) -> None:
141
+ if env == "development":
142
+ self._config = Config(
143
+ api_base_url="http://api.securedeploy.local",
144
+ auth_base_url="http://localhost",
145
+ )
146
+ else:
147
+ self._config = Config()
148
+ self.auth = _Auth(self)
149
+ self.workspaces = _Workspaces(self)
150
+ self.applications = _Applications(self)
151
+ self.defaults = _Defaults(self)
152
+ self.upload = _Upload(self)
153
+
154
+ def whoami(self) -> dict[str, Any]:
155
+ users = self._request("GET", "/v1/users")
156
+ if not users or len(users) == 0:
157
+ raise RuntimeError("Failed to get user details")
158
+ return users[0]
159
+
160
+ def _request(
161
+ self,
162
+ method: str,
163
+ path: str,
164
+ *,
165
+ json: Optional[dict[str, Any]] = None,
166
+ params: Optional[dict[str, Any]] = None,
167
+ reauth: bool = True,
168
+ ) -> Any:
169
+ url = f"{self._config.api_base_url.rstrip('/')}{path}"
170
+ headers = {"Authorization": f"Bearer {self._config.get_access_token()}"}
171
+ with _upstream_request_guard(), httpx.Client(timeout=60.0) as client:
172
+ r = client.request(method, url, headers=headers, json=json, params=params)
173
+ if r.status_code >= 400:
174
+ retry: Optional[Callable[[], Any]] = (
175
+ (
176
+ lambda: self._request(
177
+ method, path, json=json, params=params, reauth=False
178
+ )
179
+ )
180
+ if reauth
181
+ else None
182
+ )
183
+ return _handle_api_error_response(r, method, path, self.auth, retry)
184
+ if not r.content:
185
+ return None
186
+ return r.json()
187
+
188
+ def _save_config(self) -> None:
189
+ self._config.config_file.write_text(
190
+ tomli_w.dumps(self._config.config), encoding="utf-8"
191
+ )
192
+
193
+ def _default_workspace_slug(self) -> str:
194
+ slug = self._config.config.get("default_workspace")
195
+ if not slug:
196
+ raise RuntimeError("No default workspace; set one or pass workspace=")
197
+ return str(slug)
198
+
199
+ def _default_application_slug(self) -> str:
200
+ slug = self._config.config.get("default_application")
201
+ if not slug:
202
+ raise RuntimeError("No default application; set one or pass application=")
203
+ return str(slug)
204
+
205
+ def _default_application_workspace_slug(self) -> str:
206
+ slug = self._config.config.get("default_application_workspace")
207
+ if not slug:
208
+ raise RuntimeError(
209
+ "No default application workspace; set one or pass application_workspace="
210
+ )
211
+ return str(slug)
212
+
213
+
214
+ class _Auth:
215
+ def __init__(self, client: SecureDeploy) -> None:
216
+ self._client = client
217
+
218
+ def login(self, verification_callback: Optional[Callable] = None) -> None:
219
+ oauth = OAuth(self._client._config)
220
+ response = oauth.create_device_code()
221
+ verification_uri = response.get("verification_uri")
222
+ if verification_uri:
223
+ logger.info(f"Please go to this URL to authorize:\n{verification_uri}")
224
+ if verification_callback:
225
+ verification_callback(verification_uri)
226
+ webbrowser.open(str(verification_uri))
227
+ tokens = oauth.poll_for_token()
228
+ self._client._config.set_tokens(
229
+ tokens.get("access_token"), tokens.get("refresh_token")
230
+ )
231
+
232
+ def refresh_token(self) -> None:
233
+ oauth = OAuth(self._client._config)
234
+ tokens = oauth.refresh_access_token()
235
+ self._client._config.set_tokens(
236
+ tokens.get("access_token"), tokens.get("refresh_token")
237
+ )
238
+
239
+ def inspect(self) -> dict[str, Any]:
240
+ c = self._client._config.config
241
+ return {
242
+ "access_token": c.get("access_token"),
243
+ "refresh_token": c.get("refresh_token"),
244
+ }
245
+
246
+
247
+ class _Defaults:
248
+ def __init__(self, client: SecureDeploy) -> None:
249
+ self._client = client
250
+
251
+ def list(self) -> dict[str, Any]:
252
+ return {
253
+ "default_workspace": self._client._config.config.get("default_workspace"),
254
+ "default_application": self._client._config.config.get(
255
+ "default_application"
256
+ ),
257
+ "default_application_workspace": self._client._config.config.get(
258
+ "default_application_workspace"
259
+ ),
260
+ }
261
+
262
+
263
+ class _Workspaces:
264
+ def __init__(self, client: SecureDeploy) -> None:
265
+ self._client = client
266
+
267
+ def list(self) -> list[dict[str, Any]]:
268
+ return self._client._request("GET", "/v1/workspaces")
269
+
270
+ def get(self, slug: str) -> Workspace:
271
+ data = self._client._request("GET", f"/v1/workspaces/{slug}")
272
+ return Workspace(self._client, slug, data if isinstance(data, dict) else {})
273
+
274
+ def get_default(self) -> Workspace:
275
+ slug = self._client._default_workspace_slug()
276
+ return self.get(slug)
277
+
278
+ def set_default(self, slug: str) -> None:
279
+ self.get(slug)
280
+ self._client._config.config["default_workspace"] = slug
281
+ self._client._save_config()
282
+
283
+ def create(
284
+ self,
285
+ *,
286
+ name: Optional[str] = None,
287
+ slug: Optional[str] = None,
288
+ ) -> Workspace:
289
+ body = {k: v for k, v in {"name": name, "slug": slug}.items() if v is not None}
290
+ if not body:
291
+ raise ValueError("workspaces.create requires at least name or slug")
292
+ data = self._client._request("POST", "/v1/workspaces", json=body)
293
+ if not isinstance(data, dict):
294
+ raise RuntimeError("Unexpected API response for workspace create")
295
+ ws_slug = str(data.get("slug") or slug or "")
296
+ if not ws_slug:
297
+ raise RuntimeError("API did not return a workspace slug")
298
+ return Workspace(self._client, ws_slug, data)
299
+
300
+
301
+ class Workspace:
302
+ def __init__(self, client: SecureDeploy, slug: str, data: dict[str, Any]) -> None:
303
+ self._client = client
304
+ self.slug = slug
305
+ self.data = data
306
+
307
+ @property
308
+ def applications(self) -> _ScopedApplications:
309
+ return _ScopedApplications(self._client, self.slug)
310
+
311
+
312
+ class Application:
313
+ def __init__(
314
+ self,
315
+ client: SecureDeploy,
316
+ workspace_slug: str,
317
+ slug: str,
318
+ data: dict[str, Any],
319
+ ) -> None:
320
+ self._client = client
321
+ self.workspace_slug = workspace_slug
322
+ self.slug = slug
323
+ self.data = data
324
+
325
+
326
+ class _Applications:
327
+ def __init__(self, client: SecureDeploy) -> None:
328
+ self._client = client
329
+
330
+ def list(self, *, workspace: Optional[str] = None) -> list[dict[str, Any]]:
331
+ ws = workspace or self._client._default_workspace_slug()
332
+ return self._client._request("GET", f"/v1/workspaces/{ws}/applications")
333
+
334
+ def get(
335
+ self,
336
+ slug: Optional[str] = None,
337
+ *,
338
+ workspace: Optional[str] = None,
339
+ ) -> Application:
340
+ if slug is None:
341
+ slug = self._client._default_application_slug()
342
+ ws = (
343
+ self._client._default_application_workspace_slug()
344
+ or self._client._default_workspace_slug()
345
+ )
346
+ else:
347
+ ws = workspace or self._client._default_workspace_slug()
348
+ data = self._client._request("GET", f"/v1/workspaces/{ws}/applications/{slug}")
349
+ return Application(
350
+ self._client,
351
+ ws,
352
+ slug,
353
+ data if isinstance(data, dict) else {},
354
+ )
355
+
356
+ def create(
357
+ self,
358
+ *,
359
+ name: Optional[str] = None,
360
+ slug: Optional[str] = None,
361
+ workspace: Optional[str] = None,
362
+ ) -> dict[str, Any]:
363
+ ws = workspace or self._client._default_workspace_slug()
364
+ body = {k: v for k, v in {"name": name, "slug": slug}.items() if v is not None}
365
+ if not body:
366
+ body = {}
367
+ return self._client._request(
368
+ "POST", f"/v1/workspaces/{ws}/applications", json=body
369
+ )
370
+
371
+ def set_default(
372
+ self,
373
+ slug: str,
374
+ *,
375
+ workspace: Optional[str] = None,
376
+ ) -> None:
377
+ ws = workspace or self._client._default_workspace_slug()
378
+ try:
379
+ self.get(slug, workspace=ws)
380
+ except ResourceNotFoundError:
381
+ raise RuntimeError(f"Application {slug} not found in workspace {ws}")
382
+ self._client._config.config["default_application"] = slug
383
+ if not self._client._config.config.get("default_workspace"):
384
+ self._client._config.config["default_workspace"] = ws
385
+ elif self._client._config.config.get("default_workspace") != ws:
386
+ self._client._config.config["default_application_workspace"] = ws
387
+
388
+ self._client._save_config()
389
+
390
+
391
+ class _ScopedApplications:
392
+ def __init__(self, client: SecureDeploy, workspace_slug: str) -> None:
393
+ self._client = client
394
+ self._workspace = workspace_slug
395
+
396
+ def list(self) -> list[dict[str, Any]]:
397
+ return self._client.applications.list(workspace=self._workspace)
398
+
399
+ def get(self, slug: Optional[str] = None) -> Application:
400
+ return self._client.applications.get(slug, workspace=self._workspace)
401
+
402
+ def create(
403
+ self,
404
+ *,
405
+ name: Optional[str] = None,
406
+ slug: Optional[str] = None,
407
+ ) -> dict[str, Any]:
408
+ return self._client.applications.create(
409
+ name=name, slug=slug, workspace=self._workspace
410
+ )
411
+
412
+ def set_default(self, slug: str) -> None:
413
+ self._client.applications.set_default(slug, workspace=self._workspace)
414
+
415
+
416
+ class _Upload:
417
+ def __init__(self, client: SecureDeploy) -> None:
418
+ self._client = client
419
+
420
+ def file(
421
+ self,
422
+ path: Union[str, Path],
423
+ *,
424
+ application: Optional[str] = None,
425
+ workspace: Optional[str] = None,
426
+ url_path: Optional[str] = None,
427
+ ) -> Any:
428
+ p = Path(path)
429
+ if not p.is_file():
430
+ raise FileNotFoundError(p)
431
+ try:
432
+ file_size = p.stat().st_size
433
+ except OSError as e:
434
+ raise OSError(f"cannot stat {p}") from e
435
+ if file_size > _MAX_UPLOAD_FILE_BYTES:
436
+ raise ValueError(f"{p}: file size {file_size} bytes exceeds 10MiB limit")
437
+ ws = workspace or self._client._default_workspace_slug()
438
+ app = application or self._client._default_application_slug()
439
+ upload_path = f"/v1/workspaces/{ws}/applications/{app}/upload"
440
+ url = f"{self._client._config.api_base_url.rstrip('/')}{upload_path}"
441
+
442
+ def _post_upload(reauth: bool = True) -> Any:
443
+ headers = {
444
+ "Authorization": f"Bearer {self._client._config.get_access_token()}"
445
+ }
446
+ with _upstream_request_guard(), httpx.Client(timeout=120.0) as http:
447
+ with p.open("rb") as f:
448
+ # Ordered multipart parts: url_path field first, then file (httpx preserves
449
+ # sequence order; (None, value) is a non-file form field).
450
+ parts: list[tuple[str, Any]] = []
451
+ if url_path is not None:
452
+ parts.append(("url_path", (None, url_path)))
453
+ parts.append(("file", (p.name, f)))
454
+ r = http.post(url, headers=headers, files=parts)
455
+ if r.status_code >= 400:
456
+ return _handle_api_error_response(
457
+ r,
458
+ "POST",
459
+ upload_path,
460
+ self._client.auth,
461
+ _post_upload if reauth else None,
462
+ )
463
+ if not r.content:
464
+ return None
465
+ return r.json()
466
+
467
+ return _post_upload()
468
+
469
+ def path(
470
+ self,
471
+ dir_path: Union[str, Path],
472
+ *,
473
+ application: Optional[str] = None,
474
+ workspace: Optional[str] = None,
475
+ url_path: Optional[str] = None,
476
+ ) -> list[Any]:
477
+ root = Path(dir_path)
478
+ if not root.is_dir():
479
+ raise NotADirectoryError(root)
480
+ prefix = (url_path or "").strip().replace("\\", "/").strip("/")
481
+ results: list[Any] = []
482
+ for file_path in root.rglob("*"):
483
+ if not file_path.is_file():
484
+ continue
485
+ skip_reason = _upload_ignore_reason(file_path)
486
+ if skip_reason is not None:
487
+ logger.warning(f"Skipped {file_path}: {skip_reason}")
488
+ continue
489
+ try:
490
+ size = file_path.stat().st_size
491
+ except OSError as e:
492
+ logger.warning(f"Skipped {file_path}: cannot read file metadata ({e})")
493
+ continue
494
+ if size > _MAX_UPLOAD_FILE_BYTES:
495
+ logger.warning(
496
+ f"Skipped {file_path}: exceeds 10MiB limit "
497
+ f"({size} bytes > {_MAX_UPLOAD_FILE_BYTES} bytes)"
498
+ )
499
+ continue
500
+ rel = file_path.relative_to(root).as_posix()
501
+ results.append(
502
+ self.file(
503
+ file_path,
504
+ application=application,
505
+ workspace=workspace,
506
+ url_path=(f"{prefix}/{rel}" if prefix else None),
507
+ )
508
+ )
509
+ return results
securedeploy/config.py ADDED
@@ -0,0 +1,55 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ import click
6
+ import tomli_w
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else:
11
+ import tomli as tomllib
12
+
13
+
14
+ class ConfigError(Exception):
15
+ pass
16
+
17
+
18
+ class Config:
19
+ def __init__(
20
+ self,
21
+ *,
22
+ api_base_url: Optional[str] = None,
23
+ auth_base_url: Optional[str] = None,
24
+ ):
25
+ self.api_base_url = api_base_url or "https://api.securedeploy.org"
26
+ self.auth_base_url = auth_base_url or "https://auth.securedeploy.org"
27
+ self.oauth_client_id = "27r2dr2ei9vagomf4096gcsdf5"
28
+ self.config_dir = Path(click.get_app_dir("securedeploy"))
29
+ self.config_file = self.config_dir / "config.toml"
30
+ self.config_dir.mkdir(parents=True, exist_ok=True)
31
+ self.config_file.touch(exist_ok=True)
32
+ self.reload_config()
33
+
34
+ def get_access_token(self):
35
+
36
+ access_token = self.config.get("access_token")
37
+ if not access_token:
38
+ raise ConfigError("No access token found")
39
+ return access_token
40
+
41
+ def get_refresh_token(self):
42
+ refresh_token = self.config.get("refresh_token")
43
+ if not refresh_token:
44
+ raise ConfigError("No refresh token found")
45
+ return refresh_token
46
+
47
+ def set_tokens(self, access_token, refresh_token):
48
+ self.config["access_token"] = access_token
49
+ self.config["refresh_token"] = refresh_token
50
+ self.config_file.write_text(tomli_w.dumps(self.config), encoding="utf-8")
51
+ self.reload_config()
52
+
53
+ def reload_config(self):
54
+ content = self.config_file.read_text(encoding="utf-8").strip()
55
+ self.config = tomllib.loads(content) if content else {}
securedeploy/oauth.py ADDED
@@ -0,0 +1,86 @@
1
+ import time
2
+
3
+ import httpx
4
+
5
+ from securedeploy.config import Config
6
+
7
+
8
+ class OAuthFailure(Exception):
9
+ pass
10
+
11
+
12
+ class OAuthTokenExpired(Exception):
13
+ pass
14
+
15
+
16
+ class OAuth:
17
+ def __init__(self, cfg: Config):
18
+ self._cfg = cfg
19
+ self.client_id = self._cfg.oauth_client_id
20
+ base = self._cfg.auth_base_url.rstrip("/")
21
+ self.device_code_endpoint = f"{base}/device/code"
22
+ self.token_endpoint = f"{base}/token"
23
+ self.interval = 5
24
+
25
+ def create_device_code(self):
26
+ response = httpx.post(
27
+ self.device_code_endpoint,
28
+ data={"client_id": self.client_id, "scope": "openid profile email"},
29
+ headers={
30
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
31
+ },
32
+ )
33
+ if response.status_code != 200:
34
+ raise OAuthFailure(f"Failed to create device code: {response.text}")
35
+ response = response.json()
36
+ self.interval = response.get("interval", 5)
37
+ self.device_code = response.get("device_code")
38
+ self.user_code = response.get("user_code")
39
+ self.expires_in = response.get("expires_in")
40
+ self.verification_uri = response.get("verification_uri")
41
+ return response
42
+
43
+ def poll_for_token(self):
44
+ while True:
45
+ response = httpx.post(
46
+ self.token_endpoint,
47
+ data={
48
+ "client_id": self.client_id,
49
+ "device_code": self.device_code,
50
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
51
+ },
52
+ headers={
53
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
54
+ },
55
+ )
56
+ response_json = response.json()
57
+ if response.status_code == 200:
58
+ return response_json
59
+ elif response.status_code == 400:
60
+ if response_json.get("error") == "authorization_pending":
61
+ time.sleep(self.interval)
62
+ elif response_json.get("error") == "slow_down":
63
+ self.interval *= 2
64
+ time.sleep(self.interval)
65
+ elif response_json.get("error") == "expired_token":
66
+ raise OAuthTokenExpired("Token expired: Please login again.")
67
+ else:
68
+ raise OAuthFailure(f"Failed to poll for token: {response.text}")
69
+ else:
70
+ raise OAuthFailure(f"Failed to poll for token: {response.text}")
71
+
72
+ def refresh_access_token(self):
73
+ response = httpx.post(
74
+ self.token_endpoint,
75
+ data={
76
+ "client_id": self.client_id,
77
+ "grant_type": "refresh_token",
78
+ "refresh_token": self._cfg.get_refresh_token(),
79
+ },
80
+ headers={
81
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
82
+ },
83
+ )
84
+ if response.status_code != 200:
85
+ raise OAuthFailure(f"Failed to refresh access token: {response.text}")
86
+ return response.json()
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: securedeploy
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Project-URL: Homepage, https://securedeploy.org
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click>=8.1.0
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: tomli-w>=1.2.0
11
+ Requires-Dist: tomli>=1.2.0; python_version < "3.11"
@@ -0,0 +1,10 @@
1
+ securedeploy/__init__.py,sha256=xVJAWTxppE4us2IuwIcYt2014Ho70cPMX5JXqCekGhA,73
2
+ securedeploy/cli.py,sha256=FrSOBEB1sZAaYSv-bRhyDop6pBoViouqKYeicA-OYUw,8013
3
+ securedeploy/client.py,sha256=f-qedSySZnFlBNjeiRIb4vS_JVqhWne2dpSK4tZScZ0,17395
4
+ securedeploy/config.py,sha256=syVYjQ_PJ96hBMGf9nuLTL90sP4NSdKopF-mgP8nnb4,1690
5
+ securedeploy/oauth.py,sha256=bG3RQwss-xARptAmg5sFrEGei1VVJXD0Jbnv0jt9LpM,3101
6
+ securedeploy-0.1.0.dist-info/METADATA,sha256=VQk0E4vMfGkZNgBNRz5yFfJMJwJiak4Kj4nfrGJfnRs,342
7
+ securedeploy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ securedeploy-0.1.0.dist-info/entry_points.txt,sha256=xT-GOedjtxAIGdksaMHenRWzASPVBXTz1pfNW7tM7nE,54
9
+ securedeploy-0.1.0.dist-info/top_level.txt,sha256=0_6AVwCEoBPfaw-I9UZ44TxN88W8PMjLD38th1LMCfc,13
10
+ securedeploy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ securedeploy = securedeploy.cli:cli
@@ -0,0 +1 @@
1
+ securedeploy