developer-platform-cli 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,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: developer-platform-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line client for the Developer Platform API
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx<1,>=0.28
7
+ Requires-Dist: pydantic<3,>=2.10
8
+ Requires-Dist: rich<14,>=13.9
9
+ Requires-Dist: typer<1,>=0.15
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9,>=8.3; extra == 'dev'
12
+ Requires-Dist: respx<1,>=0.22; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Developer Platform CLI
16
+
17
+ `devplat` is the command-line client for the Developer Platform API. Its first
18
+ supported workflow is CI-style deployment using a project-scoped deployment
19
+ credential supplied only through environment variables.
20
+
21
+ Install an editable development copy:
22
+
23
+ ```bash
24
+ python3 -m pip install -e 'platform/cli[dev]'
25
+ ```
26
+
27
+ Set the values returned when the deployment credential was created. Keep the secret
28
+ in the CI system's protected environment and disable shell tracing around this step:
29
+
30
+ ```bash
31
+ export PLATFORM_API_URL=https://platform.example
32
+ export PLATFORM_TOKEN_ENDPOINT=https://identity.example/realms/platform/protocol/openid-connect/token
33
+ export PLATFORM_CLIENT_ID='...'
34
+ export PLATFORM_CLIENT_SECRET='...'
35
+ devplat deploy apply --project hello --file examples/project.json --wait
36
+ ```
37
+
38
+ `PLATFORM_ACCESS_TOKEN` may be used instead of the deployment-credential variables
39
+ for a short-lived, externally obtained token. The CLI never persists either form of
40
+ authentication material.
@@ -0,0 +1,10 @@
1
+ platform_cli/__init__.py,sha256=3pnCImon2ZJc0V_dh8ESALZOalenXMwbD0ftP4lo2cM,69
2
+ platform_cli/__main__.py,sha256=l-D8wwSK-ZOwuXMXuqf5LBTREn2KyjF3yutKtEDwRKc,5289
3
+ platform_cli/client.py,sha256=_EUtgFpzNCCR2ZXUMhjzqFAqEYpSlFPA7aS1NDNkT6c,5506
4
+ platform_cli/config.py,sha256=fn1pwa-cNN5uGeqNdWm49NKjet_8A-We37JyTFJWi6M,2819
5
+ platform_cli/errors.py,sha256=i_wsaqmuLXo0gV-B5WkA9WS188lSKSgNtrBvr9AMf_U,1176
6
+ platform_cli/models.py,sha256=YPVBF7S-aERxr1J6FJmithEJn4ZWYSkMPS5a0vgdJRE,1162
7
+ developer_platform_cli-0.1.0.dist-info/METADATA,sha256=Uv_wlIyRec9kSLLfpiLWcRtOR9N7kagIVAUYjDIZJ7o,1429
8
+ developer_platform_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ developer_platform_cli-0.1.0.dist-info/entry_points.txt,sha256=M7MpiSzj86vx01JPP3zjnrykkQuxJjTnS9o5Rs0CAkI,55
10
+ developer_platform_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devplat = platform_cli.__main__:main
@@ -0,0 +1,3 @@
1
+ """Developer Platform command-line client."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,128 @@
1
+ """The devplat executable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+ import time
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from .client import OperationFailed, OperationTimeout, PlatformClient, wait_for_operation
14
+ from .config import ConfigurationError, authentication_source, runtime_config
15
+ from .errors import ApiError
16
+ from .models import OperationStatus, application_body
17
+
18
+
19
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="Developer Platform command-line client.")
20
+ deploy_app = typer.Typer(no_args_is_help=True, help="Deploy versioned application specifications.")
21
+ auth_app = typer.Typer(no_args_is_help=True, help="Inspect the active non-persistent authentication source.")
22
+ app.add_typer(deploy_app, name="deploy")
23
+ app.add_typer(auth_app, name="auth")
24
+
25
+
26
+ def _read_json(path: str) -> dict[str, object]:
27
+ try:
28
+ contents = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
29
+ return application_body(json.loads(contents))
30
+ except (OSError, json.JSONDecodeError, ValueError) as error:
31
+ raise typer.BadParameter(f"unable to read a JSON application specification: {error}", param_hint="--file") from error
32
+
33
+
34
+ def _emit(value: object, output: str) -> None:
35
+ if output == "json":
36
+ typer.echo(json.dumps(value, separators=(",", ":"), default=str))
37
+ return
38
+ if isinstance(value, dict) and "operation_id" in value:
39
+ typer.echo(f"Operation {value['operation_id']} is {value.get('state', 'unknown')} (revision {value.get('revision', 'unknown')}).")
40
+ return
41
+ typer.echo(str(value))
42
+
43
+
44
+ def _failure(error: Exception, output: str) -> typer.Exit:
45
+ if isinstance(error, ApiError):
46
+ payload = {"status": error.status_code, "detail": error.detail, "code": error.code, "request_id": error.request_id}
47
+ if output == "json":
48
+ typer.echo(json.dumps(payload, separators=(",", ":")), err=True)
49
+ else:
50
+ typer.echo(error.detail, err=True)
51
+ return typer.Exit(error.exit_code)
52
+ if isinstance(error, ConfigurationError):
53
+ typer.echo(str(error), err=True)
54
+ return typer.Exit(2)
55
+ if isinstance(error, OperationFailed):
56
+ typer.echo(f"Operation {error.status.operation_id} failed: {error}", err=True)
57
+ return typer.Exit(9)
58
+ if isinstance(error, OperationTimeout):
59
+ typer.echo(f"{error} Resume with devplat operations watch {error.operation_id} when available.", err=True)
60
+ return typer.Exit(10)
61
+ typer.echo(str(error), err=True)
62
+ return typer.Exit(8)
63
+
64
+
65
+ @auth_app.command("status")
66
+ def auth_status(
67
+ output: Annotated[str, typer.Option("--output", case_sensitive=False)] = "table",
68
+ ) -> None:
69
+ """Show the active authentication source without disclosing credentials."""
70
+ try:
71
+ source = authentication_source(runtime_config())
72
+ _emit({"source": source, "persistent": False}, output)
73
+ except Exception as error:
74
+ raise _failure(error, output) from None
75
+
76
+
77
+ @deploy_app.command("apply")
78
+ def deploy_apply(
79
+ project: Annotated[str, typer.Option("--project", min=1, help="Target project name.")],
80
+ file: Annotated[str, typer.Option("--file", help="JSON specification file, or - for stdin.")],
81
+ wait: Annotated[bool, typer.Option("--wait", help="Wait for durable operation success and readiness.")] = False,
82
+ timeout: Annotated[float, typer.Option("--timeout", min=1.0, help="Maximum wait time in seconds.")] = 300.0,
83
+ poll_interval: Annotated[float, typer.Option("--poll-interval", min=0.1, help="Operation poll interval in seconds.")] = 2.0,
84
+ if_match: Annotated[str | None, typer.Option("--if-match", help="Expected current revision.")] = None,
85
+ output: Annotated[str, typer.Option("--output", case_sensitive=False)] = "table",
86
+ ) -> None:
87
+ """Apply one JSON application specification using environment authentication."""
88
+ if output not in {"table", "json", "plain"}:
89
+ raise typer.BadParameter("must be table, json, or plain", param_hint="--output")
90
+ try:
91
+ body = _read_json(file)
92
+ config = runtime_config()
93
+ client = PlatformClient(config)
94
+ try:
95
+ token = client.access_token()
96
+ accepted = client.deploy(project, body, token, if_match)
97
+ if not wait:
98
+ _emit(accepted.model_dump(), output)
99
+ return
100
+
101
+ def update(status: OperationStatus) -> None:
102
+ if output != "json":
103
+ typer.echo(
104
+ f"Operation {status.operation_id}: {status.state}; readiness {status.readiness.state}.",
105
+ err=True,
106
+ )
107
+
108
+ completed = wait_for_operation(
109
+ client, accepted, token, timeout, poll_interval, time.monotonic, time.sleep, update
110
+ )
111
+ _emit(completed.model_dump(), output)
112
+ finally:
113
+ client.close()
114
+ except typer.BadParameter:
115
+ raise
116
+ except KeyboardInterrupt:
117
+ typer.echo("Waiting interrupted; the durable platform operation continues.", err=True)
118
+ raise typer.Exit(10) from None
119
+ except Exception as error:
120
+ raise _failure(error, output) from None
121
+
122
+
123
+ def main() -> None:
124
+ app()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
platform_cli/client.py ADDED
@@ -0,0 +1,127 @@
1
+ """HTTP and OIDC client boundary for deployment credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from pydantic import ValidationError
10
+
11
+ from .config import DeploymentCredentialAuth, RuntimeConfig, authentication_source
12
+ from .errors import ApiError, safe_error_body
13
+ from .models import OperationAccepted, OperationStatus, TokenResponse
14
+
15
+
16
+ class PlatformClient:
17
+ def __init__(self, config: RuntimeConfig, http_client: httpx.Client | None = None):
18
+ self.config = config
19
+ self._http = http_client or httpx.Client(timeout=httpx.Timeout(30.0), verify=config.ca_bundle or True)
20
+ self._owns_http = http_client is None
21
+
22
+ def close(self) -> None:
23
+ if self._owns_http:
24
+ self._http.close()
25
+
26
+ def access_token(self) -> str:
27
+ source = authentication_source(self.config)
28
+ if source == "access-token-environment":
29
+ assert self.config.access_token_auth is not None
30
+ return self.config.access_token_auth.access_token
31
+ assert self.config.deployment_credential_auth is not None
32
+ return self._client_credentials_token(self.config.deployment_credential_auth)
33
+
34
+ def deploy(self, project: str, body: dict[str, Any], access_token: str, if_match: str | None = None) -> OperationAccepted:
35
+ headers = {"Authorization": f"Bearer {access_token}"}
36
+ if if_match is not None:
37
+ headers["If-Match"] = if_match
38
+ response = self._request("PUT", f"/projects/{project}", headers=headers, json=body)
39
+ try:
40
+ return OperationAccepted.model_validate(response.json())
41
+ except (ValidationError, ValueError) as error:
42
+ raise RuntimeError("The platform returned an invalid deployment operation.") from error
43
+
44
+ def operation(self, status_url: str, access_token: str) -> OperationStatus:
45
+ if not status_url.startswith("/") or status_url.startswith("//"):
46
+ raise RuntimeError("The platform returned an unsafe operation status URL.")
47
+ response = self._request("GET", status_url, headers={"Authorization": f"Bearer {access_token}"})
48
+ try:
49
+ return OperationStatus.model_validate(response.json())
50
+ except (ValidationError, ValueError) as error:
51
+ raise RuntimeError("The platform returned an invalid operation status.") from error
52
+
53
+ def _client_credentials_token(self, credential: DeploymentCredentialAuth) -> str:
54
+ try:
55
+ response = self._http.post(
56
+ credential.token_endpoint,
57
+ data={
58
+ "grant_type": "client_credentials",
59
+ "client_id": credential.client_id,
60
+ "client_secret": credential.client_secret,
61
+ },
62
+ )
63
+ except httpx.HTTPError as error:
64
+ raise RuntimeError("Unable to reach the OIDC token endpoint.") from error
65
+ self._raise_for_error(response)
66
+ try:
67
+ token = TokenResponse.model_validate(response.json())
68
+ except (ValidationError, ValueError) as error:
69
+ raise RuntimeError("The OIDC provider returned an invalid token response.") from error
70
+ if token.token_type.casefold() != "bearer":
71
+ raise RuntimeError("The OIDC provider returned an unsupported token type.")
72
+ return token.access_token
73
+
74
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
75
+ try:
76
+ response = self._http.request(method, self.config.api_url + path, **kwargs)
77
+ except httpx.HTTPError as error:
78
+ raise RuntimeError("Unable to reach the Platform API.") from error
79
+ self._raise_for_error(response)
80
+ return response
81
+
82
+ @staticmethod
83
+ def _raise_for_error(response: httpx.Response) -> None:
84
+ if response.is_success:
85
+ return
86
+ try:
87
+ detail, code = safe_error_body(response.json())
88
+ except ValueError:
89
+ detail, code = "The platform returned an error without a safe detail.", None
90
+ request_id = response.headers.get("x-request-id") or response.headers.get("x-correlation-id")
91
+ raise ApiError(response.status_code, detail, code, request_id)
92
+
93
+
94
+ def wait_for_operation(
95
+ client: PlatformClient,
96
+ accepted: OperationAccepted,
97
+ access_token: str,
98
+ timeout_seconds: float,
99
+ interval_seconds: float,
100
+ clock: Callable[[], float],
101
+ sleep: Callable[[float], None],
102
+ on_update: Callable[[OperationStatus], None],
103
+ ) -> OperationStatus:
104
+ """Wait for apply success and live readiness without cancelling a durable operation."""
105
+ deadline = clock() + timeout_seconds
106
+ while True:
107
+ status = client.operation(accepted.status_url, access_token)
108
+ on_update(status)
109
+ if status.state in {"failed", "cancelled"}:
110
+ raise OperationFailed(status)
111
+ if status.state == "succeeded" and status.readiness.state == "ready":
112
+ return status
113
+ if clock() >= deadline:
114
+ raise OperationTimeout(accepted.operation_id)
115
+ sleep(interval_seconds)
116
+
117
+
118
+ class OperationFailed(RuntimeError):
119
+ def __init__(self, status: OperationStatus):
120
+ super().__init__(status.error_code or "The platform operation failed.")
121
+ self.status = status
122
+
123
+
124
+ class OperationTimeout(TimeoutError):
125
+ def __init__(self, operation_id: str):
126
+ super().__init__(f"Timed out while waiting for operation {operation_id}.")
127
+ self.operation_id = operation_id
platform_cli/config.py ADDED
@@ -0,0 +1,81 @@
1
+ """Non-persistent authentication configuration for the first CLI increment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+
8
+
9
+ class ConfigurationError(ValueError):
10
+ """Raised when CLI authentication environment is incomplete or invalid."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AccessTokenAuth:
15
+ access_token: str
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DeploymentCredentialAuth:
20
+ token_endpoint: str
21
+ client_id: str
22
+ client_secret: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RuntimeConfig:
27
+ api_url: str
28
+ ca_bundle: str | None
29
+ access_token_auth: AccessTokenAuth | None
30
+ deployment_credential_auth: DeploymentCredentialAuth | None
31
+
32
+
33
+ def runtime_config(environment: dict[str, str] | None = None) -> RuntimeConfig:
34
+ """Read the non-persistent command configuration from an environment mapping."""
35
+ env = os.environ if environment is None else environment
36
+ api_url = env.get("PLATFORM_API_URL", "").rstrip("/")
37
+ if not api_url:
38
+ raise ConfigurationError("PLATFORM_API_URL is required")
39
+
40
+ token = env.get("PLATFORM_ACCESS_TOKEN")
41
+ if token:
42
+ return RuntimeConfig(
43
+ api_url=api_url,
44
+ ca_bundle=env.get("PLATFORM_CA_BUNDLE") or None,
45
+ access_token_auth=AccessTokenAuth(token),
46
+ deployment_credential_auth=None,
47
+ )
48
+
49
+ credential_values = {
50
+ "PLATFORM_TOKEN_ENDPOINT": env.get("PLATFORM_TOKEN_ENDPOINT"),
51
+ "PLATFORM_CLIENT_ID": env.get("PLATFORM_CLIENT_ID"),
52
+ "PLATFORM_CLIENT_SECRET": env.get("PLATFORM_CLIENT_SECRET"),
53
+ }
54
+ present = {name for name, value in credential_values.items() if value}
55
+ if present and len(present) != len(credential_values):
56
+ missing = sorted(set(credential_values) - present)
57
+ raise ConfigurationError("deployment credential environment is incomplete; missing " + ", ".join(missing))
58
+
59
+ credential = None
60
+ if len(present) == len(credential_values):
61
+ credential = DeploymentCredentialAuth(
62
+ token_endpoint=credential_values["PLATFORM_TOKEN_ENDPOINT"] or "",
63
+ client_id=credential_values["PLATFORM_CLIENT_ID"] or "",
64
+ client_secret=credential_values["PLATFORM_CLIENT_SECRET"] or "",
65
+ )
66
+ return RuntimeConfig(
67
+ api_url=api_url,
68
+ ca_bundle=env.get("PLATFORM_CA_BUNDLE") or None,
69
+ access_token_auth=None,
70
+ deployment_credential_auth=credential,
71
+ )
72
+
73
+
74
+ def authentication_source(config: RuntimeConfig) -> str:
75
+ if config.access_token_auth is not None:
76
+ return "access-token-environment"
77
+ if config.deployment_credential_auth is not None:
78
+ return "deployment-credential-environment"
79
+ raise ConfigurationError(
80
+ "set PLATFORM_ACCESS_TOKEN or PLATFORM_TOKEN_ENDPOINT, PLATFORM_CLIENT_ID, and PLATFORM_CLIENT_SECRET"
81
+ )
platform_cli/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """Safe API error mapping and stable command exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ApiError(Exception):
11
+ status_code: int
12
+ detail: str
13
+ code: str | None = None
14
+ request_id: str | None = None
15
+
16
+ @property
17
+ def exit_code(self) -> int:
18
+ return exit_code_for_status(self.status_code)
19
+
20
+
21
+ def exit_code_for_status(status_code: int) -> int:
22
+ if status_code in {401}:
23
+ return 3
24
+ if status_code == 403:
25
+ return 4
26
+ if status_code == 404:
27
+ return 5
28
+ if status_code == 409:
29
+ return 6
30
+ if status_code in {400, 422}:
31
+ return 7
32
+ if status_code == 429 or status_code >= 500:
33
+ return 8
34
+ return 8
35
+
36
+
37
+ def safe_error_body(value: Any) -> tuple[str, str | None]:
38
+ if not isinstance(value, dict):
39
+ return "The platform returned an error without a safe detail.", None
40
+ detail = value.get("detail")
41
+ code = value.get("code")
42
+ return (
43
+ detail if isinstance(detail, str) else "The platform returned an error without a safe detail.",
44
+ code if isinstance(code, str) else None,
45
+ )
platform_cli/models.py ADDED
@@ -0,0 +1,46 @@
1
+ """Strict boundary models for the first deployment workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class TokenResponse(BaseModel):
11
+ model_config = ConfigDict(extra="ignore")
12
+
13
+ access_token: str = Field(min_length=1)
14
+ token_type: str = Field(min_length=1)
15
+
16
+
17
+ class OperationAccepted(BaseModel):
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+ operation_id: str = Field(min_length=1)
21
+ state: str = Field(min_length=1)
22
+ revision: int = Field(ge=0)
23
+ status_url: str = Field(min_length=1)
24
+
25
+
26
+ class Readiness(BaseModel):
27
+ model_config = ConfigDict(extra="allow")
28
+
29
+ state: str = Field(min_length=1)
30
+ reason: str | None = None
31
+
32
+
33
+ class OperationStatus(BaseModel):
34
+ model_config = ConfigDict(extra="allow")
35
+
36
+ operation_id: str = Field(min_length=1)
37
+ state: str = Field(min_length=1)
38
+ revision: int = Field(ge=0)
39
+ error_code: str | None = None
40
+ readiness: Readiness
41
+
42
+
43
+ def application_body(value: Any) -> dict[str, Any]:
44
+ if not isinstance(value, dict):
45
+ raise ValueError("application specification must be a JSON object")
46
+ return value