cpsl 0.1.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.
- cpsl-0.1.0/PKG-INFO +11 -0
- cpsl-0.1.0/pyproject.toml +28 -0
- cpsl-0.1.0/setup.cfg +4 -0
- cpsl-0.1.0/src/cpsl/__init__.py +1 -0
- cpsl-0.1.0/src/cpsl/__main__.py +3 -0
- cpsl-0.1.0/src/cpsl/channel.py +162 -0
- cpsl-0.1.0/src/cpsl/cli/__init__.py +0 -0
- cpsl-0.1.0/src/cpsl/cli/app.py +78 -0
- cpsl-0.1.0/src/cpsl/cli/login.py +87 -0
- cpsl-0.1.0/src/cpsl/cli/main.py +22 -0
- cpsl-0.1.0/src/cpsl/clients/__init__.py +0 -0
- cpsl-0.1.0/src/cpsl/clients/capsule/__init__.py +199 -0
- cpsl-0.1.0/src/cpsl/config.py +130 -0
- cpsl-0.1.0/src/cpsl/terminal.py +24 -0
- cpsl-0.1.0/src/cpsl.egg-info/PKG-INFO +11 -0
- cpsl-0.1.0/src/cpsl.egg-info/SOURCES.txt +18 -0
- cpsl-0.1.0/src/cpsl.egg-info/dependency_links.txt +1 -0
- cpsl-0.1.0/src/cpsl.egg-info/entry_points.txt +2 -0
- cpsl-0.1.0/src/cpsl.egg-info/requires.txt +6 -0
- cpsl-0.1.0/src/cpsl.egg-info/top_level.txt +1 -0
cpsl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cpsl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capsule SDK and CLI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: grpcio>=1.69.0
|
|
7
|
+
Requires-Dist: grpclib>=0.4.7
|
|
8
|
+
Requires-Dist: protobuf>=4.25.0
|
|
9
|
+
Requires-Dist: betterproto-beta9==2.0.1
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cpsl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Capsule SDK and CLI"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"grpcio>=1.69.0",
|
|
8
|
+
"grpclib>=0.4.7",
|
|
9
|
+
"protobuf>=4.25.0",
|
|
10
|
+
"betterproto-beta9==2.0.1",
|
|
11
|
+
"click>=8.1.0",
|
|
12
|
+
"rich>=13.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
capsule = "cpsl.cli.main:start"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=42", "wheel"]
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
exclude = ["src/cpsl/clients"]
|
|
28
|
+
src = ["src"]
|
cpsl-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Capsule SDK — create and manage capsule apps."""
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Any, Callable, List, NewType, Optional, Sequence, Tuple, cast
|
|
3
|
+
|
|
4
|
+
import grpc
|
|
5
|
+
from grpc import ChannelCredentials
|
|
6
|
+
from grpc._interceptor import _Channel as InterceptorChannel
|
|
7
|
+
|
|
8
|
+
from . import terminal
|
|
9
|
+
from .config import ConfigContext, get_config_context
|
|
10
|
+
|
|
11
|
+
GRPC_MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
MetadataType = NewType("MetadataType", List[Tuple[Any, Any]])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Channel(InterceptorChannel):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
addr: str,
|
|
20
|
+
token: Optional[str] = None,
|
|
21
|
+
credentials: Optional[ChannelCredentials] = None,
|
|
22
|
+
options: Optional[Sequence[Tuple[str, Any]]] = None,
|
|
23
|
+
):
|
|
24
|
+
if options is None:
|
|
25
|
+
options = [
|
|
26
|
+
("grpc.max_receive_message_length", GRPC_MAX_MESSAGE_SIZE),
|
|
27
|
+
("grpc.max_send_message_length", GRPC_MAX_MESSAGE_SIZE),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
if credentials is not None:
|
|
31
|
+
channel = grpc.secure_channel(addr, credentials, options=options)
|
|
32
|
+
elif addr.endswith("443"):
|
|
33
|
+
channel = grpc.secure_channel(addr, grpc.ssl_channel_credentials(), options=options)
|
|
34
|
+
else:
|
|
35
|
+
channel = grpc.insecure_channel(addr, options=options)
|
|
36
|
+
|
|
37
|
+
interceptor = AuthTokenInterceptor(token)
|
|
38
|
+
super().__init__(channel=channel, interceptor=interceptor)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthTokenInterceptor(
|
|
42
|
+
grpc.UnaryUnaryClientInterceptor,
|
|
43
|
+
grpc.UnaryStreamClientInterceptor,
|
|
44
|
+
grpc.StreamUnaryClientInterceptor,
|
|
45
|
+
grpc.StreamStreamClientInterceptor,
|
|
46
|
+
):
|
|
47
|
+
def __init__(self, token: Optional[str] = None):
|
|
48
|
+
self._token = token
|
|
49
|
+
|
|
50
|
+
def _add_auth_metadata(self, client_call_details):
|
|
51
|
+
if self._token:
|
|
52
|
+
auth_headers = [("authorization", f"Bearer {self._token}")]
|
|
53
|
+
if client_call_details.metadata is not None:
|
|
54
|
+
new_metadata = client_call_details.metadata + auth_headers
|
|
55
|
+
else:
|
|
56
|
+
new_metadata = auth_headers
|
|
57
|
+
else:
|
|
58
|
+
new_metadata = client_call_details.metadata
|
|
59
|
+
|
|
60
|
+
return client_call_details._replace(metadata=cast(MetadataType, new_metadata))
|
|
61
|
+
|
|
62
|
+
def intercept_call(self, continuation, client_call_details, request):
|
|
63
|
+
new_details = self._add_auth_metadata(client_call_details)
|
|
64
|
+
return continuation(new_details, request)
|
|
65
|
+
|
|
66
|
+
def intercept_call_stream(self, continuation, client_call_details, request_iterator):
|
|
67
|
+
return self.intercept_call(continuation, client_call_details, request=request_iterator)
|
|
68
|
+
|
|
69
|
+
intercept_unary_unary = intercept_call
|
|
70
|
+
intercept_unary_stream = intercept_call
|
|
71
|
+
intercept_stream_unary = intercept_call_stream
|
|
72
|
+
intercept_stream_stream = intercept_call_stream
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_channel(context: Optional[ConfigContext] = None) -> Channel:
|
|
76
|
+
if not context:
|
|
77
|
+
context = get_config_context()
|
|
78
|
+
|
|
79
|
+
if not context or not context.is_valid():
|
|
80
|
+
raise RuntimeError("No valid config. Run 'capsule login' first.")
|
|
81
|
+
|
|
82
|
+
return Channel(
|
|
83
|
+
addr=f"{context.gateway_host}:{context.gateway_port}",
|
|
84
|
+
token=context.token,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def handle_grpc_error(error: grpc.RpcError) -> None:
|
|
89
|
+
code = error.code()
|
|
90
|
+
details = error.details()
|
|
91
|
+
|
|
92
|
+
if code == grpc.StatusCode.UNAUTHENTICATED:
|
|
93
|
+
terminal.error("Unauthorized. Run 'capsule login' to authenticate.")
|
|
94
|
+
elif code == grpc.StatusCode.UNAVAILABLE:
|
|
95
|
+
terminal.error("Unable to connect to gateway.")
|
|
96
|
+
elif code == grpc.StatusCode.CANCELLED:
|
|
97
|
+
return
|
|
98
|
+
else:
|
|
99
|
+
terminal.error(f"gRPC error: {code} — {details}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ServiceClient:
|
|
103
|
+
def __init__(self, config: Optional[ConfigContext] = None) -> None:
|
|
104
|
+
self._config = config
|
|
105
|
+
self._channel: Optional[Channel] = None
|
|
106
|
+
self._stub = None
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> "ServiceClient":
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def with_channel(cls, channel: Channel) -> "ServiceClient":
|
|
116
|
+
client = cls()
|
|
117
|
+
client.channel = channel
|
|
118
|
+
return client
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def channel(self) -> Channel:
|
|
122
|
+
if not self._channel:
|
|
123
|
+
self._channel = get_channel(self._config)
|
|
124
|
+
return self._channel
|
|
125
|
+
|
|
126
|
+
@channel.setter
|
|
127
|
+
def channel(self, value: Channel) -> None:
|
|
128
|
+
if not value or not isinstance(value, Channel):
|
|
129
|
+
raise ValueError("Invalid channel")
|
|
130
|
+
self._channel = value
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def capsule(self):
|
|
134
|
+
if not self._stub:
|
|
135
|
+
from .clients.capsule import CapsuleServiceStub
|
|
136
|
+
|
|
137
|
+
self._stub = CapsuleServiceStub(self.channel)
|
|
138
|
+
return self._stub
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
if self._channel:
|
|
142
|
+
self._channel.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def pass_service_client(func: Callable) -> Callable:
|
|
146
|
+
"""Decorator that creates a ServiceClient and passes it as the first arg."""
|
|
147
|
+
|
|
148
|
+
@functools.wraps(func)
|
|
149
|
+
def wrapper(*args, **kwargs):
|
|
150
|
+
ctx = get_config_context()
|
|
151
|
+
if not ctx or not ctx.is_valid():
|
|
152
|
+
terminal.error("Not logged in. Run 'capsule login' first.")
|
|
153
|
+
raise SystemExit(1)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
with ServiceClient(ctx) as client:
|
|
157
|
+
return func(client, *args, **kwargs)
|
|
158
|
+
except grpc.RpcError as e:
|
|
159
|
+
handle_grpc_error(e)
|
|
160
|
+
raise SystemExit(1)
|
|
161
|
+
|
|
162
|
+
return wrapper
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
|
|
4
|
+
from .. import terminal
|
|
5
|
+
from ..channel import ServiceClient, pass_service_client
|
|
6
|
+
from ..clients.capsule import CreateAppRequest, GetAppRequest, ListAppsRequest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def app():
|
|
11
|
+
"""Manage apps."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("create")
|
|
16
|
+
@click.option("--name", required=True, help="App name")
|
|
17
|
+
@click.option("--price", default=100, type=int, help="Price in cents")
|
|
18
|
+
@pass_service_client
|
|
19
|
+
def create(client: ServiceClient, name: str, price: int):
|
|
20
|
+
"""Create a new app."""
|
|
21
|
+
res = client.capsule.create_app(
|
|
22
|
+
CreateAppRequest(name=name, price_in_cents=price)
|
|
23
|
+
)
|
|
24
|
+
if not res.ok:
|
|
25
|
+
terminal.error(f"Failed: {res.err_msg}")
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
|
|
28
|
+
terminal.success(f"Created app \"{res.app.name}\" at {res.app.hostname}.capsule.new")
|
|
29
|
+
terminal.info(f" ID: {res.app.id}")
|
|
30
|
+
terminal.info(f" Hostname: {res.app.hostname}")
|
|
31
|
+
terminal.info(f" Price: {res.app.price_in_cents}¢")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("list")
|
|
35
|
+
@pass_service_client
|
|
36
|
+
def list_apps(client: ServiceClient):
|
|
37
|
+
"""List your apps."""
|
|
38
|
+
res = client.capsule.list_apps(ListAppsRequest())
|
|
39
|
+
if not res.ok:
|
|
40
|
+
terminal.error(f"Failed: {res.err_msg}")
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
if not res.apps:
|
|
44
|
+
terminal.info("No apps yet. Create one with 'capsule app create'.")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
from rich.console import Console
|
|
48
|
+
|
|
49
|
+
table = Table(title="Apps")
|
|
50
|
+
table.add_column("Name")
|
|
51
|
+
table.add_column("ID")
|
|
52
|
+
table.add_column("Hostname")
|
|
53
|
+
table.add_column("Price")
|
|
54
|
+
table.add_column("Created")
|
|
55
|
+
|
|
56
|
+
for a in res.apps:
|
|
57
|
+
table.add_row(a.name, a.id, a.hostname, f"{a.price_in_cents}¢", a.created_at)
|
|
58
|
+
|
|
59
|
+
Console().print(table)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command("get")
|
|
63
|
+
@click.argument("app_id")
|
|
64
|
+
@pass_service_client
|
|
65
|
+
def get(client: ServiceClient, app_id: str):
|
|
66
|
+
"""Get details of an app by ID."""
|
|
67
|
+
res = client.capsule.get_app(GetAppRequest(id=app_id))
|
|
68
|
+
if not res.ok:
|
|
69
|
+
terminal.error(f"Failed: {res.err_msg}")
|
|
70
|
+
raise SystemExit(1)
|
|
71
|
+
|
|
72
|
+
a = res.app
|
|
73
|
+
terminal.header(a.name)
|
|
74
|
+
terminal.info(f" ID: {a.id}")
|
|
75
|
+
terminal.info(f" Hostname: {a.hostname}")
|
|
76
|
+
terminal.info(f" Owner: {a.owner_id}")
|
|
77
|
+
terminal.info(f" Price: {a.price_in_cents}¢")
|
|
78
|
+
terminal.info(f" Created: {a.created_at}")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import webbrowser
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.spinner import Spinner
|
|
7
|
+
from rich.live import Live
|
|
8
|
+
|
|
9
|
+
from .. import terminal
|
|
10
|
+
from ..channel import Channel, ServiceClient
|
|
11
|
+
from ..clients.capsule import ConfirmLoginRequest, RequestLoginRequest
|
|
12
|
+
from ..config import (
|
|
13
|
+
DEFAULT_CONTEXT_NAME,
|
|
14
|
+
ConfigContext,
|
|
15
|
+
get_settings,
|
|
16
|
+
load_config,
|
|
17
|
+
save_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
POLL_INTERVAL_S = 2
|
|
21
|
+
POLL_TIMEOUT_S = 300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
@click.option("--host", default=None, help="Gateway host")
|
|
26
|
+
@click.option("--port", default=None, type=int, help="Gateway port")
|
|
27
|
+
def login(host: str | None, port: int | None):
|
|
28
|
+
"""Authenticate with Capsule via browser.
|
|
29
|
+
|
|
30
|
+
Set CAPSULE_GATEWAY_URL=localhost:1980 to target a local dev server.
|
|
31
|
+
"""
|
|
32
|
+
settings = get_settings()
|
|
33
|
+
gateway_host = host or settings.gateway_host
|
|
34
|
+
gateway_port = port or settings.gateway_port
|
|
35
|
+
|
|
36
|
+
channel = Channel(addr=f"{gateway_host}:{gateway_port}", token=None)
|
|
37
|
+
|
|
38
|
+
with ServiceClient.with_channel(channel) as client:
|
|
39
|
+
res = client.capsule.request_login(RequestLoginRequest())
|
|
40
|
+
if not res.ok:
|
|
41
|
+
terminal.error(f"Login failed: {res.err_msg}")
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
|
|
44
|
+
login_url = res.login_url
|
|
45
|
+
terminal.header("Opening browser to authenticate...")
|
|
46
|
+
terminal.info(f"{login_url}\n")
|
|
47
|
+
|
|
48
|
+
if not webbrowser.open(login_url):
|
|
49
|
+
terminal.warn("Could not open browser. Open the URL above manually.")
|
|
50
|
+
|
|
51
|
+
confirm = _poll_with_spinner(client, res.login_id)
|
|
52
|
+
|
|
53
|
+
context = ConfigContext(
|
|
54
|
+
token=confirm.token,
|
|
55
|
+
gateway_host=gateway_host,
|
|
56
|
+
gateway_port=gateway_port,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
contexts = load_config()
|
|
60
|
+
contexts[DEFAULT_CONTEXT_NAME] = context
|
|
61
|
+
save_config(contexts)
|
|
62
|
+
|
|
63
|
+
terminal.success(f"\nAuthenticated! Workspace: {confirm.workspace_id}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _poll_with_spinner(client: ServiceClient, login_id: str):
|
|
67
|
+
console = Console()
|
|
68
|
+
deadline = time.time() + POLL_TIMEOUT_S
|
|
69
|
+
|
|
70
|
+
with Live(
|
|
71
|
+
Spinner("dots", text="Waiting for authentication in the browser..."),
|
|
72
|
+
console=console,
|
|
73
|
+
transient=True,
|
|
74
|
+
):
|
|
75
|
+
while time.time() < deadline:
|
|
76
|
+
time.sleep(POLL_INTERVAL_S)
|
|
77
|
+
|
|
78
|
+
res = client.capsule.confirm_login(ConfirmLoginRequest(login_id=login_id))
|
|
79
|
+
if res.ok:
|
|
80
|
+
return res
|
|
81
|
+
|
|
82
|
+
if res.err_msg not in ("pending",):
|
|
83
|
+
terminal.error(f"Login failed: {res.err_msg}")
|
|
84
|
+
raise SystemExit(1)
|
|
85
|
+
|
|
86
|
+
terminal.error("Login timed out. Please try again.")
|
|
87
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
os.environ.setdefault("GRPC_VERBOSITY", "ERROR")
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .login import login
|
|
8
|
+
from .app import app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def cli():
|
|
13
|
+
"""Capsule CLI — create and manage capsule apps."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
cli.add_command(login)
|
|
18
|
+
cli.add_command(app)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def start():
|
|
22
|
+
cli()
|
|
File without changes
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# sources: capsule.proto
|
|
3
|
+
# plugin: python-betterproto
|
|
4
|
+
# This file has been @generated
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Dict,
|
|
10
|
+
List,
|
|
11
|
+
Optional,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import betterproto
|
|
15
|
+
import grpc
|
|
16
|
+
from betterproto.grpcstub.grpcio_client import SyncServiceStub
|
|
17
|
+
from betterproto.grpcstub.grpclib_server import ServiceBase
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import grpclib.server
|
|
21
|
+
from betterproto.grpcstub.grpclib_client import MetadataLike
|
|
22
|
+
from grpclib.metadata import Deadline
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(eq=False, repr=False)
|
|
26
|
+
class App(betterproto.Message):
|
|
27
|
+
id: str = betterproto.string_field(1)
|
|
28
|
+
name: str = betterproto.string_field(2)
|
|
29
|
+
hostname: str = betterproto.string_field(3)
|
|
30
|
+
owner_id: str = betterproto.string_field(4)
|
|
31
|
+
created_at: str = betterproto.string_field(5)
|
|
32
|
+
updated_at: str = betterproto.string_field(6)
|
|
33
|
+
price_in_cents: int = betterproto.int32_field(7)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(eq=False, repr=False)
|
|
37
|
+
class CreateAppRequest(betterproto.Message):
|
|
38
|
+
name: str = betterproto.string_field(1)
|
|
39
|
+
hostname: str = betterproto.string_field(2)
|
|
40
|
+
price_in_cents: int = betterproto.int32_field(3)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(eq=False, repr=False)
|
|
44
|
+
class CreateAppResponse(betterproto.Message):
|
|
45
|
+
ok: bool = betterproto.bool_field(1)
|
|
46
|
+
app: "App" = betterproto.message_field(2)
|
|
47
|
+
err_msg: str = betterproto.string_field(3)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(eq=False, repr=False)
|
|
51
|
+
class GetAppRequest(betterproto.Message):
|
|
52
|
+
id: str = betterproto.string_field(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(eq=False, repr=False)
|
|
56
|
+
class GetAppResponse(betterproto.Message):
|
|
57
|
+
ok: bool = betterproto.bool_field(1)
|
|
58
|
+
app: "App" = betterproto.message_field(2)
|
|
59
|
+
err_msg: str = betterproto.string_field(3)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(eq=False, repr=False)
|
|
63
|
+
class ListAppsRequest(betterproto.Message):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(eq=False, repr=False)
|
|
68
|
+
class ListAppsResponse(betterproto.Message):
|
|
69
|
+
ok: bool = betterproto.bool_field(1)
|
|
70
|
+
apps: List["App"] = betterproto.message_field(2)
|
|
71
|
+
err_msg: str = betterproto.string_field(3)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(eq=False, repr=False)
|
|
75
|
+
class ResolveRequest(betterproto.Message):
|
|
76
|
+
hostname: str = betterproto.string_field(1)
|
|
77
|
+
access_token: str = betterproto.string_field(2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(eq=False, repr=False)
|
|
81
|
+
class ResolveResponse(betterproto.Message):
|
|
82
|
+
ok: bool = betterproto.bool_field(1)
|
|
83
|
+
app: "App" = betterproto.message_field(2)
|
|
84
|
+
has_access: bool = betterproto.bool_field(3)
|
|
85
|
+
err_msg: str = betterproto.string_field(4)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(eq=False, repr=False)
|
|
89
|
+
class CreateCheckoutRequest(betterproto.Message):
|
|
90
|
+
app_id: str = betterproto.string_field(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(eq=False, repr=False)
|
|
94
|
+
class CreateCheckoutResponse(betterproto.Message):
|
|
95
|
+
ok: bool = betterproto.bool_field(1)
|
|
96
|
+
checkout_url: str = betterproto.string_field(2)
|
|
97
|
+
err_msg: str = betterproto.string_field(3)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(eq=False, repr=False)
|
|
101
|
+
class AuthorizeRequest(betterproto.Message):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(eq=False, repr=False)
|
|
106
|
+
class AuthorizeResponse(betterproto.Message):
|
|
107
|
+
ok: bool = betterproto.bool_field(1)
|
|
108
|
+
workspace_id: str = betterproto.string_field(2)
|
|
109
|
+
err_msg: str = betterproto.string_field(3)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(eq=False, repr=False)
|
|
113
|
+
class RequestLoginRequest(betterproto.Message):
|
|
114
|
+
email: str = betterproto.string_field(1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(eq=False, repr=False)
|
|
118
|
+
class RequestLoginResponse(betterproto.Message):
|
|
119
|
+
ok: bool = betterproto.bool_field(1)
|
|
120
|
+
login_id: str = betterproto.string_field(2)
|
|
121
|
+
err_msg: str = betterproto.string_field(3)
|
|
122
|
+
login_url: str = betterproto.string_field(4)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(eq=False, repr=False)
|
|
126
|
+
class ConfirmLoginRequest(betterproto.Message):
|
|
127
|
+
login_id: str = betterproto.string_field(1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(eq=False, repr=False)
|
|
131
|
+
class ConfirmLoginResponse(betterproto.Message):
|
|
132
|
+
ok: bool = betterproto.bool_field(1)
|
|
133
|
+
token: str = betterproto.string_field(2)
|
|
134
|
+
workspace_id: str = betterproto.string_field(3)
|
|
135
|
+
err_msg: str = betterproto.string_field(4)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CapsuleServiceStub(SyncServiceStub):
|
|
139
|
+
def create_app(self, create_app_request: "CreateAppRequest") -> "CreateAppResponse":
|
|
140
|
+
return self._unary_unary(
|
|
141
|
+
"/capsule.CapsuleService/CreateApp",
|
|
142
|
+
CreateAppRequest,
|
|
143
|
+
CreateAppResponse,
|
|
144
|
+
)(create_app_request)
|
|
145
|
+
|
|
146
|
+
def get_app(self, get_app_request: "GetAppRequest") -> "GetAppResponse":
|
|
147
|
+
return self._unary_unary(
|
|
148
|
+
"/capsule.CapsuleService/GetApp",
|
|
149
|
+
GetAppRequest,
|
|
150
|
+
GetAppResponse,
|
|
151
|
+
)(get_app_request)
|
|
152
|
+
|
|
153
|
+
def list_apps(self, list_apps_request: "ListAppsRequest") -> "ListAppsResponse":
|
|
154
|
+
return self._unary_unary(
|
|
155
|
+
"/capsule.CapsuleService/ListApps",
|
|
156
|
+
ListAppsRequest,
|
|
157
|
+
ListAppsResponse,
|
|
158
|
+
)(list_apps_request)
|
|
159
|
+
|
|
160
|
+
def resolve(self, resolve_request: "ResolveRequest") -> "ResolveResponse":
|
|
161
|
+
return self._unary_unary(
|
|
162
|
+
"/capsule.CapsuleService/Resolve",
|
|
163
|
+
ResolveRequest,
|
|
164
|
+
ResolveResponse,
|
|
165
|
+
)(resolve_request)
|
|
166
|
+
|
|
167
|
+
def create_checkout(
|
|
168
|
+
self, create_checkout_request: "CreateCheckoutRequest"
|
|
169
|
+
) -> "CreateCheckoutResponse":
|
|
170
|
+
return self._unary_unary(
|
|
171
|
+
"/capsule.CapsuleService/CreateCheckout",
|
|
172
|
+
CreateCheckoutRequest,
|
|
173
|
+
CreateCheckoutResponse,
|
|
174
|
+
)(create_checkout_request)
|
|
175
|
+
|
|
176
|
+
def authorize(self, authorize_request: "AuthorizeRequest") -> "AuthorizeResponse":
|
|
177
|
+
return self._unary_unary(
|
|
178
|
+
"/capsule.CapsuleService/Authorize",
|
|
179
|
+
AuthorizeRequest,
|
|
180
|
+
AuthorizeResponse,
|
|
181
|
+
)(authorize_request)
|
|
182
|
+
|
|
183
|
+
def request_login(
|
|
184
|
+
self, request_login_request: "RequestLoginRequest"
|
|
185
|
+
) -> "RequestLoginResponse":
|
|
186
|
+
return self._unary_unary(
|
|
187
|
+
"/capsule.CapsuleService/RequestLogin",
|
|
188
|
+
RequestLoginRequest,
|
|
189
|
+
RequestLoginResponse,
|
|
190
|
+
)(request_login_request)
|
|
191
|
+
|
|
192
|
+
def confirm_login(
|
|
193
|
+
self, confirm_login_request: "ConfirmLoginRequest"
|
|
194
|
+
) -> "ConfirmLoginResponse":
|
|
195
|
+
return self._unary_unary(
|
|
196
|
+
"/capsule.CapsuleService/ConfirmLogin",
|
|
197
|
+
ConfirmLoginRequest,
|
|
198
|
+
ConfirmLoginResponse,
|
|
199
|
+
)(confirm_login_request)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import configparser
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import MutableMapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONTEXT_NAME = "default"
|
|
8
|
+
DEFAULT_GATEWAY_HOST = "gateway.capsule.new"
|
|
9
|
+
DEFAULT_GATEWAY_PORT = 443
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_gateway_url(raw: str) -> tuple[str, int]:
|
|
13
|
+
"""Parse a host:port string, defaulting port to 443 if omitted."""
|
|
14
|
+
raw = raw.strip()
|
|
15
|
+
if ":" in raw:
|
|
16
|
+
host, port_s = raw.rsplit(":", 1)
|
|
17
|
+
return host, int(port_s)
|
|
18
|
+
return raw, DEFAULT_GATEWAY_PORT
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SDKSettings:
|
|
23
|
+
name: str = "Capsule"
|
|
24
|
+
gateway_host: str = DEFAULT_GATEWAY_HOST
|
|
25
|
+
gateway_port: int = DEFAULT_GATEWAY_PORT
|
|
26
|
+
config_path: Path = Path("~/.capsule/config.ini").expanduser()
|
|
27
|
+
api_token: Optional[str] = os.getenv("CAPSULE_TOKEN")
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
if p := os.getenv("CONFIG_PATH"):
|
|
31
|
+
self.config_path = Path(p).expanduser()
|
|
32
|
+
|
|
33
|
+
if url := os.getenv("CAPSULE_GATEWAY_URL"):
|
|
34
|
+
self.gateway_host, self.gateway_port = _parse_gateway_url(url)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ConfigContext:
|
|
39
|
+
token: Optional[str] = None
|
|
40
|
+
gateway_host: Optional[str] = None
|
|
41
|
+
gateway_port: Optional[int] = None
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
return {k: ("" if not v else v) for k, v in asdict(self).items()}
|
|
45
|
+
|
|
46
|
+
def is_valid(self) -> bool:
|
|
47
|
+
return all([self.token, self.gateway_host, self.gateway_port])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_SETTINGS: Optional[SDKSettings] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_settings() -> SDKSettings:
|
|
54
|
+
global _SETTINGS
|
|
55
|
+
if not _SETTINGS:
|
|
56
|
+
_SETTINGS = SDKSettings()
|
|
57
|
+
return _SETTINGS
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_settings(s: SDKSettings) -> None:
|
|
61
|
+
global _SETTINGS
|
|
62
|
+
_SETTINGS = s
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_config(path: Optional[Union[str, Path]] = None) -> MutableMapping[str, ConfigContext]:
|
|
66
|
+
if path is None:
|
|
67
|
+
path = get_settings().config_path
|
|
68
|
+
|
|
69
|
+
path = Path(path)
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
parser = configparser.ConfigParser(default_section=DEFAULT_CONTEXT_NAME)
|
|
74
|
+
parser.read(path)
|
|
75
|
+
|
|
76
|
+
contexts: MutableMapping[str, ConfigContext] = {}
|
|
77
|
+
for name, section in parser.items():
|
|
78
|
+
port = section.get("gateway_port")
|
|
79
|
+
contexts[name] = ConfigContext(
|
|
80
|
+
token=section.get("token"),
|
|
81
|
+
gateway_host=section.get("gateway_host"),
|
|
82
|
+
gateway_port=int(port) if port else None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return contexts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_config(
|
|
89
|
+
contexts: MutableMapping[str, ConfigContext],
|
|
90
|
+
path: Optional[Union[Path, str]] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
if not contexts:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if path is None:
|
|
96
|
+
path = get_settings().config_path
|
|
97
|
+
|
|
98
|
+
path = Path(path)
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
parser = configparser.ConfigParser(default_section=DEFAULT_CONTEXT_NAME)
|
|
102
|
+
parser.read_dict({k: v.to_dict() for k, v in contexts.items()})
|
|
103
|
+
|
|
104
|
+
with open(path, "w") as f:
|
|
105
|
+
parser.write(f)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_config_empty(path: Optional[Union[Path, str]] = None) -> bool:
|
|
109
|
+
if path is None:
|
|
110
|
+
path = get_settings().config_path
|
|
111
|
+
|
|
112
|
+
path = Path(path)
|
|
113
|
+
return not path.exists()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_config_context(name: str = DEFAULT_CONTEXT_NAME) -> Optional[ConfigContext]:
|
|
117
|
+
contexts = load_config()
|
|
118
|
+
if name in contexts:
|
|
119
|
+
return contexts[name]
|
|
120
|
+
|
|
121
|
+
settings = get_settings()
|
|
122
|
+
token = os.getenv("CAPSULE_TOKEN", settings.api_token)
|
|
123
|
+
if token:
|
|
124
|
+
return ConfigContext(
|
|
125
|
+
token=token,
|
|
126
|
+
gateway_host=settings.gateway_host,
|
|
127
|
+
gateway_port=settings.gateway_port,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
|
|
3
|
+
_console = Console()
|
|
4
|
+
_err_console = Console(stderr=True)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def header(text: str) -> None:
|
|
8
|
+
_console.print(f"\n[bold]{text}[/]")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def info(text: str) -> None:
|
|
12
|
+
_console.print(text)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def success(text: str) -> None:
|
|
16
|
+
_console.print(f"[green]{text}[/]")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def warn(text: str) -> None:
|
|
20
|
+
_err_console.print(f"[yellow]{text}[/]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def error(text: str) -> None:
|
|
24
|
+
_err_console.print(f"[red]{text}[/]")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cpsl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capsule SDK and CLI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: grpcio>=1.69.0
|
|
7
|
+
Requires-Dist: grpclib>=0.4.7
|
|
8
|
+
Requires-Dist: protobuf>=4.25.0
|
|
9
|
+
Requires-Dist: betterproto-beta9==2.0.1
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/cpsl/__init__.py
|
|
3
|
+
src/cpsl/__main__.py
|
|
4
|
+
src/cpsl/channel.py
|
|
5
|
+
src/cpsl/config.py
|
|
6
|
+
src/cpsl/terminal.py
|
|
7
|
+
src/cpsl.egg-info/PKG-INFO
|
|
8
|
+
src/cpsl.egg-info/SOURCES.txt
|
|
9
|
+
src/cpsl.egg-info/dependency_links.txt
|
|
10
|
+
src/cpsl.egg-info/entry_points.txt
|
|
11
|
+
src/cpsl.egg-info/requires.txt
|
|
12
|
+
src/cpsl.egg-info/top_level.txt
|
|
13
|
+
src/cpsl/cli/__init__.py
|
|
14
|
+
src/cpsl/cli/app.py
|
|
15
|
+
src/cpsl/cli/login.py
|
|
16
|
+
src/cpsl/cli/main.py
|
|
17
|
+
src/cpsl/clients/__init__.py
|
|
18
|
+
src/cpsl/clients/capsule/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cpsl
|