secryn-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.
secryn_cli/client.py ADDED
@@ -0,0 +1,206 @@
1
+ """HTTP client for the Secryn API.
2
+
3
+ Manages authentication cookies, request serialisation, and error handling.
4
+ """
5
+
6
+ import json
7
+ from typing import Any, Optional
8
+ from urllib.parse import urljoin
9
+
10
+ import requests
11
+
12
+ from .config import Config, clear_cookies, load_cookies, save_cookies
13
+
14
+
15
+ class APIError(Exception):
16
+ """Raised when the Secryn API returns a non-2xx status code.
17
+
18
+ Attributes:
19
+ status_code: HTTP status code returned by the server.
20
+ message: Human-readable error description.
21
+ code: Machine-readable error identifier.
22
+ details: Optional structured context (e.g. validation errors).
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ status_code: int,
28
+ message: str,
29
+ code: str = "",
30
+ details: Any = None,
31
+ ) -> None:
32
+ self.status_code = status_code
33
+ self.message = message
34
+ self.code = code
35
+ self.details = details
36
+ super().__init__(message)
37
+
38
+ def __str__(self) -> str:
39
+ if self.details:
40
+ return f"{self.message} ({self.code}): {self.details}"
41
+ return f"{self.message} ({self.code})"
42
+
43
+
44
+ class Client:
45
+ """Low-level HTTP client that talks to the Secryn API.
46
+
47
+ Automatically persists and restores authentication cookies between
48
+ invocations so that a login survives across CLI sessions.
49
+
50
+ Args:
51
+ config: The CLI configuration object holding the API base URL
52
+ and user metadata.
53
+ """
54
+
55
+ def __init__(self, config: Config) -> None:
56
+ self.config = config
57
+ self.session = requests.Session()
58
+ self.session.headers.update(
59
+ {
60
+ "Content-Type": "application/json",
61
+ "Accept": "application/json",
62
+ "User-Agent": config.user_agent,
63
+ }
64
+ )
65
+
66
+ cookies = load_cookies()
67
+ if cookies:
68
+ for cookie in cookies:
69
+ self.session.cookies.set(
70
+ cookie["name"],
71
+ cookie["value"],
72
+ domain=cookie.get("domain", ""),
73
+ path=cookie.get("path", "/"),
74
+ )
75
+
76
+ def _url(self, path: str) -> str:
77
+ """Resolve an API path against the configured base URL."""
78
+ return urljoin(self.config.api_url.rstrip("/") + "/", path.lstrip("/"))
79
+
80
+ def _persist_cookies(self) -> None:
81
+ """Write the current session cookies to disk for future reuse."""
82
+ cookies = []
83
+ for cookie in self.session.cookies:
84
+ cookies.append(
85
+ {
86
+ "name": cookie.name,
87
+ "value": cookie.value,
88
+ "domain": cookie.domain,
89
+ "path": cookie.path,
90
+ "expires": cookie.expires,
91
+ "secure": cookie.secure,
92
+ }
93
+ )
94
+ save_cookies(cookies)
95
+
96
+ def _request(
97
+ self,
98
+ method: str,
99
+ path: str,
100
+ body: Optional[dict] = None,
101
+ raw: bool = False,
102
+ ) -> Any:
103
+ """Execute an HTTP request against the API.
104
+
105
+ Args:
106
+ method: HTTP method (``GET``, ``POST``, ``PUT``, ``DELETE``).
107
+ path: API path relative to the base URL.
108
+ body: Optional JSON-serialisable request body.
109
+ raw: When ``True``, returns the raw response text instead of
110
+ parsing JSON.
111
+
112
+ Returns:
113
+ Parsed JSON response body, raw response text, or ``None`` for
114
+ 204 No Content.
115
+
116
+ Raises:
117
+ APIError: The server returned a 4xx or 5xx status code.
118
+ """
119
+ url = self._url(path)
120
+ kwargs: dict = {}
121
+ if body is not None:
122
+ kwargs["json"] = body
123
+
124
+ resp = self.session.request(method, url, **kwargs)
125
+ self._persist_cookies()
126
+
127
+ if raw:
128
+ if resp.status_code >= 400:
129
+ try:
130
+ err = resp.json()
131
+ raise APIError(
132
+ resp.status_code,
133
+ err.get("message", resp.text),
134
+ err.get("code", ""),
135
+ err.get("details"),
136
+ )
137
+ except (json.JSONDecodeError, ValueError):
138
+ raise APIError(resp.status_code, resp.text)
139
+ return resp.text
140
+
141
+ if resp.status_code == 204 or not resp.text.strip():
142
+ if resp.status_code >= 400:
143
+ raise APIError(resp.status_code, "Request failed", str(resp.status_code))
144
+ return None
145
+
146
+ try:
147
+ data = resp.json()
148
+ except (json.JSONDecodeError, ValueError):
149
+ if resp.status_code >= 400:
150
+ raise APIError(resp.status_code, resp.text)
151
+ return resp.text
152
+
153
+ if resp.status_code >= 400:
154
+ raise APIError(
155
+ resp.status_code,
156
+ data.get("message", "Request failed"),
157
+ data.get("code", ""),
158
+ data.get("details"),
159
+ )
160
+
161
+ return data
162
+
163
+ def get(self, path: str) -> Any:
164
+ """Send a GET request to ``path`` and return the parsed JSON body."""
165
+ return self._request("GET", path)
166
+
167
+ def post(self, path: str, body: Optional[dict] = None) -> Any:
168
+ """Send a POST request to ``path`` with an optional JSON body."""
169
+ return self._request("POST", path, body)
170
+
171
+ def put(self, path: str, body: Optional[dict] = None) -> Any:
172
+ """Send a PUT request to ``path`` with an optional JSON body."""
173
+ return self._request("PUT", path, body)
174
+
175
+ def delete(self, path: str) -> Any:
176
+ """Send a DELETE request to ``path``."""
177
+ return self._request("DELETE", path)
178
+
179
+ def get_raw(self, path: str) -> str:
180
+ """Send a GET request to ``path`` and return the raw response text.
181
+
182
+ Used for endpoints that return non-JSON content (e.g. ``.env`` export).
183
+ """
184
+ result = self._request("GET", path, raw=True)
185
+ return result if result is not None else ""
186
+
187
+ def save_config(self) -> None:
188
+ """Persist the CLI configuration to disk."""
189
+ from .config import save_config as _save_config
190
+
191
+ _save_config(self.config)
192
+
193
+ def logout(self) -> None:
194
+ """Log out and clear all locally stored credentials.
195
+
196
+ Attempts to notify the server, then removes the cookie jar and
197
+ resets user identity fields.
198
+ """
199
+ try:
200
+ self.post("/auth/logout")
201
+ except Exception:
202
+ pass
203
+ clear_cookies()
204
+ self.config.user_id = None
205
+ self.config.user_email = None
206
+ self.save_config()
secryn_cli/config.py ADDED
@@ -0,0 +1,161 @@
1
+ """Configuration management for the Secryn CLI.
2
+
3
+ Stores API URL, user identity, and persisted cookies under
4
+ ``~/.config/secryn/`` (or the platform-appropriate equivalent).
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import platform
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ APP_NAME = "secryn"
14
+ API_BASE_PATH = "/api/v1"
15
+
16
+
17
+ def config_dir() -> Path:
18
+ """Return the platform-specific configuration directory.
19
+
20
+ Resolution order:
21
+ 1. ``SECRYN_HOME`` environment variable.
22
+ 2. Windows: ``%APPDATA%\\secryn``.
23
+ 3. Linux/macOS: ``$XDG_CONFIG_HOME/secryn`` or
24
+ ``~/.config/secryn``.
25
+
26
+ Returns:
27
+ Absolute path to the configuration directory.
28
+ """
29
+ env_home = os.environ.get("SECRYN_HOME")
30
+ if env_home:
31
+ return Path(env_home)
32
+
33
+ if platform.system() == "Windows":
34
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
35
+ return base / APP_NAME
36
+
37
+ xdg = os.environ.get("XDG_CONFIG_HOME", "")
38
+ if xdg:
39
+ return Path(xdg) / APP_NAME
40
+
41
+ return Path.home() / ".config" / APP_NAME
42
+
43
+
44
+ def config_path() -> Path:
45
+ """Return the path to the main configuration file (``config.json``)."""
46
+ return config_dir() / "config.json"
47
+
48
+
49
+ def cookie_jar_path() -> Path:
50
+ """Return the path to the persisted cookie jar (``cookies.json``)."""
51
+ return config_dir() / "cookies.json"
52
+
53
+
54
+ class Config:
55
+ """In-memory representation of the CLI configuration.
56
+
57
+ Attributes:
58
+ api_url: Base URL of the Secryn API including the ``/api/v1`` prefix.
59
+ user_id: ID of the currently authenticated user, if any.
60
+ user_email: Email of the currently authenticated user, if any.
61
+ version: CLI version string.
62
+ """
63
+
64
+ def __init__(self) -> None:
65
+ self.api_url: str = f"http://localhost:3000{API_BASE_PATH}"
66
+ self.user_id: Optional[str] = None
67
+ self.user_email: Optional[str] = None
68
+ self.version: str = "0.1.0"
69
+
70
+ @property
71
+ def user_agent(self) -> str:
72
+ """``User-Agent`` header value sent with every API request."""
73
+ return f"secryn-cli/{self.version}"
74
+
75
+
76
+ def load_config() -> Config:
77
+ """Load configuration from disk, falling back to defaults.
78
+
79
+ Creates the config directory with restricted permissions if it does
80
+ not exist yet.
81
+
82
+ Returns:
83
+ A ``Config`` instance populated with stored values or defaults.
84
+ """
85
+ cfg = Config()
86
+
87
+ config_dir().mkdir(parents=True, exist_ok=True)
88
+ config_dir().chmod(0o700)
89
+
90
+ path = config_path()
91
+ if not path.exists():
92
+ return cfg
93
+
94
+ try:
95
+ data = json.loads(path.read_text())
96
+ cfg.api_url = data.get("api_url", cfg.api_url)
97
+ cfg.user_id = data.get("user_id")
98
+ cfg.user_email = data.get("user_email")
99
+ cfg.version = data.get("version", cfg.version)
100
+ except (json.JSONDecodeError, ValueError):
101
+ pass
102
+
103
+ return cfg
104
+
105
+
106
+ def save_config(cfg: Config) -> None:
107
+ """Persist the current configuration to disk.
108
+
109
+ Writes ``config.json`` with mode ``0600`` inside the config directory.
110
+
111
+ Args:
112
+ cfg: The ``Config`` instance to persist.
113
+ """
114
+ config_dir().mkdir(parents=True, exist_ok=True)
115
+ config_dir().chmod(0o700)
116
+
117
+ data = {
118
+ "api_url": cfg.api_url,
119
+ "user_id": cfg.user_id,
120
+ "user_email": cfg.user_email,
121
+ "version": cfg.version,
122
+ }
123
+ config_path().write_text(json.dumps(data, indent=2))
124
+ config_path().chmod(0o600)
125
+
126
+
127
+ def load_cookies() -> Optional[dict]:
128
+ """Load persisted session cookies from disk.
129
+
130
+ Returns:
131
+ A dictionary of serialized cookies, or ``None`` if no cookie
132
+ jar exists or it is unreadable.
133
+ """
134
+ path = cookie_jar_path()
135
+ if not path.exists():
136
+ return None
137
+ try:
138
+ return json.loads(path.read_text())
139
+ except (json.JSONDecodeError, ValueError):
140
+ return None
141
+
142
+
143
+ def save_cookies(data: dict) -> None:
144
+ """Persist session cookies to disk.
145
+
146
+ Writes ``cookies.json`` with mode ``0600`` inside the config directory.
147
+
148
+ Args:
149
+ data: Serialized cookie dictionary.
150
+ """
151
+ config_dir().mkdir(parents=True, exist_ok=True)
152
+ config_dir().chmod(0o700)
153
+ cookie_jar_path().write_text(json.dumps(data, indent=2))
154
+ cookie_jar_path().chmod(0o600)
155
+
156
+
157
+ def clear_cookies() -> None:
158
+ """Remove the persisted cookie jar from disk, if it exists."""
159
+ path = cookie_jar_path()
160
+ if path.exists():
161
+ path.unlink()
File without changes