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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Capsule SDK — create and manage capsule apps."""
@@ -0,0 +1,3 @@
1
+ from cpsl.cli.main import start
2
+
3
+ start()
@@ -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,2 @@
1
+ [console_scripts]
2
+ capsule = cpsl.cli.main:start
@@ -0,0 +1,6 @@
1
+ grpcio>=1.69.0
2
+ grpclib>=0.4.7
3
+ protobuf>=4.25.0
4
+ betterproto-beta9==2.0.1
5
+ click>=8.1.0
6
+ rich>=13.0.0
@@ -0,0 +1 @@
1
+ cpsl