+pyoutlineapi
+ + + + + + +1from .client import AsyncOutlineClient, OutlineError, APIError + 2from .models import ( + 3 AccessKey, + 4 AccessKeyCreateRequest, + 5 AccessKeyList, + 6 DataLimit, + 7 ErrorResponse, + 8 ExperimentalMetrics, + 9 MetricsPeriod, +10 MetricsStatusResponse, +11 Server, +12 ServerMetrics, +13) +14 +15__version__ = "0.2.0" +16 +17__all__ = [ +18 "AsyncOutlineClient", +19 "OutlineError", +20 "APIError", +21 "AccessKey", +22 "AccessKeyCreateRequest", +23 "AccessKeyList", +24 "DataLimit", +25 "ErrorResponse", +26 "ExperimentalMetrics", +27 "MetricsPeriod", +28 "MetricsStatusResponse", +29 "Server", +30 "ServerMetrics", +31] +
37class AsyncOutlineClient: + 38 """ + 39 Asynchronous client for the Outline VPN Server API. + 40 + 41 Args: + 42 api_url: Base URL for the Outline server API + 43 cert_sha256: SHA-256 fingerprint of the server's TLS certificate + 44 json_format: Return raw JSON instead of Pydantic models + 45 timeout: Request timeout in seconds + 46 + 47 Examples: + 48 >>> async def doo_something(): + 49 ... async with AsyncOutlineClient( + 50 ... "https://example.com:1234/secret", + 51 ... "ab12cd34..." + 52 ... ) as client: + 53 ... server_info = await client.get_server_info() + 54 """ + 55 + 56 def __init__( + 57 self, + 58 api_url: str, + 59 cert_sha256: str, + 60 *, + 61 json_format: bool = True, + 62 timeout: float = 30.0, + 63 ) -> None: + 64 self._api_url = api_url.rstrip("/") + 65 self._cert_sha256 = cert_sha256 + 66 self._json_format = json_format + 67 self._timeout = aiohttp.ClientTimeout(total=timeout) + 68 self._ssl_context = None + 69 self._session: Optional[aiohttp.ClientSession] = None + 70 self._in_context = False + 71 + 72 async def __aenter__(self) -> AsyncOutlineClient: + 73 """Set up client session for context manager.""" + 74 self._session = aiohttp.ClientSession( + 75 timeout=self._timeout, raise_for_status=True + 76 ) + 77 self._in_context = True + 78 return self + 79 + 80 async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + 81 """Clean up client session.""" + 82 if self._session: + 83 await self._session.close() + 84 self._session = None + 85 self._in_context = False + 86 + 87 def _ensure_context(self): + 88 """Ensure the session context is valid.""" + 89 if not self._session or self._session.closed: + 90 raise RuntimeError("Client session is not initialized or already closed.") + 91 + 92 @overload + 93 async def _parse_response( + 94 self, + 95 response: ClientResponse, + 96 model: type[BaseModel], + 97 json_format: Literal[True], + 98 ) -> JsonDict: ... + 99 +100 @overload +101 async def _parse_response( +102 self, +103 response: ClientResponse, +104 model: type[BaseModel], +105 json_format: Literal[False], +106 ) -> BaseModel: ... +107 +108 @overload +109 async def _parse_response( +110 self, response: ClientResponse, model: type[BaseModel], json_format: bool +111 ) -> Union[JsonDict, BaseModel]: ... +112 +113 async def _parse_response( +114 self, response: ClientResponse, model: type[BaseModel], json_format: bool = True +115 ) -> Union[JsonDict, BaseModel]: +116 """ +117 Parse and validate API response data. +118 +119 Args: +120 response: API response to parse +121 model: Pydantic model for validation +122 json_format: Whether to return raw JSON +123 +124 Returns: +125 Validated response data +126 +127 Raises: +128 ValueError: If response validation fails +129 """ +130 self._ensure_context() +131 +132 try: +133 data = await response.json() +134 except aiohttp.ContentTypeError: +135 raise ValueError("Invalid response format") from None +136 try: +137 validated = model.model_validate(data) +138 return validated.model_dump() if json_format else validated +139 except Exception as e: +140 raise ValueError(f"Value error: {e}") from e +141 +142 @staticmethod +143 async def _handle_error_response(response: ClientResponse) -> None: +144 """Handle error responses from the API.""" +145 try: +146 error_data = await response.json() +147 error = ErrorResponse.model_validate(error_data) +148 raise APIError(f"{error.code}: {error.message}") +149 except ValueError: +150 raise APIError(f"HTTP {response.status}: {response.reason}") +151 +152 async def _request( +153 self, +154 method: str, +155 endpoint: str, +156 *, +157 json: Any = None, +158 params: Optional[dict[str, Any]] = None, +159 ) -> Any: +160 """Make an API request.""" +161 self._ensure_context() +162 +163 url = self._build_url(endpoint) +164 ssl_context = self._get_ssl_context() +165 +166 async with self._session.request( +167 method, +168 url, +169 json=json, +170 params=params, +171 ssl=ssl_context, +172 raise_for_status=False, +173 timeout=self._timeout, +174 ) as response: +175 if response.status >= 400: +176 await self._handle_error_response(response) +177 +178 if response.status == 204: +179 return True # No content response +180 +181 try: +182 await response.json() +183 return response +184 except aiohttp.ContentTypeError: +185 return await response.text() # Fallback for non-JSON responses +186 except Exception as e: +187 raise APIError(f"Failed to parse response from {url}: {e}") from e +188 +189 def _build_url(self, endpoint: str) -> str: +190 """Build and validate the full URL for the API request.""" +191 if not isinstance(endpoint, str): +192 raise ValueError("Endpoint must be a string") +193 +194 endpoint = endpoint.lstrip("/") +195 url = f"{self._api_url}/{endpoint}" +196 +197 parsed_url = urlparse(url) +198 if not parsed_url.scheme or not parsed_url.netloc: +199 raise ValueError(f"Invalid URL: {url}") +200 +201 return url +202 +203 def _get_ssl_context(self) -> Optional[Fingerprint]: +204 """Create an SSL context if a certificate fingerprint is provided.""" +205 if not self._cert_sha256: +206 return None +207 +208 try: +209 fingerprint = binascii.unhexlify(self._cert_sha256) +210 return Fingerprint(fingerprint) +211 except binascii.Error as e: +212 raise ValueError(f"Invalid certificate SHA256: {self._cert_sha256}") from e +213 except Exception as e: +214 raise OutlineError("Error while creating SSL context") from e +215 +216 async def get_server_info(self) -> Union[JsonDict, Server]: +217 """ +218 Get server information. +219 +220 Returns: +221 Server information including name, ID, and configuration. +222 +223 Examples: +224 >>> async def doo_something(): +225 ... async with AsyncOutlineClient( +226 ... "https://example.com:1234/secret", +227 ... "ab12cd34..." +228 ... ) as client: +229 ... server = await client.get_server_info() +230 ... print(f"Server {server.name} running version {server.version}") +231 """ +232 response = await self._request("GET", "server") +233 return await self._parse_response( +234 response, Server, json_format=self._json_format +235 ) +236 +237 async def rename_server(self, name: str) -> bool: +238 """ +239 Rename the server. +240 +241 Args: +242 name: New server name +243 +244 Returns: +245 True if successful +246 +247 Examples: +248 >>> async def doo_something(): +249 ... async with AsyncOutlineClient( +250 ... "https://example.com:1234/secret", +251 ... "ab12cd34..." +252 ... ) as client: +253 ... success = await client.rename_server("My VPN Server") +254 ... if success: +255 ... print("Server renamed successfully") +256 """ +257 return await self._request("PUT", "name", json={"name": name}) +258 +259 async def set_hostname(self, hostname: str) -> bool: +260 """ +261 Set server hostname for access keys. +262 +263 Args: +264 hostname: New hostname or IP address +265 +266 Returns: +267 True if successful +268 +269 Raises: +270 APIError: If hostname is invalid +271 +272 Examples: +273 >>> async def doo_something(): +274 ... async with AsyncOutlineClient( +275 ... "https://example.com:1234/secret", +276 ... "ab12cd34..." +277 ... ) as client: +278 ... await client.set_hostname("vpn.example.com") +279 ... # Or use IP address +280 ... await client.set_hostname("203.0.113.1") +281 """ +282 return await self._request( +283 "PUT", "server/hostname-for-access-keys", json={"hostname": hostname} +284 ) +285 +286 async def set_default_port(self, port: int) -> bool: +287 """ +288 Set default port for new access keys. +289 +290 Args: +291 port: Port number (1025-65535) +292 +293 Returns: +294 True if successful +295 +296 Raises: +297 APIError: If port is invalid or in use +298 +299 Examples: +300 >>> async def doo_something(): +301 ... async with AsyncOutlineClient( +302 ... "https://example.com:1234/secret", +303 ... "ab12cd34..." +304 ... ) as client: +305 ... await client.set_default_port(8388) +306 +307 """ +308 return await self._request( +309 "PUT", "server/port-for-new-access-keys", json={"port": port} +310 ) +311 +312 async def get_metrics_status(self) -> dict[str, Any] | BaseModel: +313 """ +314 Get whether metrics collection is enabled. +315 +316 Returns: +317 Current metrics collection status +318 +319 Examples: +320 >>> async def doo_something(): +321 ... async with AsyncOutlineClient( +322 ... "https://example.com:1234/secret", +323 ... "ab12cd34..." +324 ... ) as client: +325 ... if await client.get_metrics_status(): +326 ... print("Metrics collection is enabled") +327 """ +328 response = await self._request("GET", "metrics/enabled") +329 data = await self._parse_response( +330 response, MetricsStatusResponse, json_format=self._json_format +331 ) +332 return data +333 +334 async def set_metrics_status(self, enabled: bool) -> bool: +335 """ +336 Enable or disable metrics collection. +337 +338 Args: +339 enabled: Whether to enable metrics +340 +341 Returns: +342 True if successful +343 +344 Examples: +345 >>> async def doo_something(): +346 ... async with AsyncOutlineClient( +347 ... "https://example.com:1234/secret", +348 ... "ab12cd34..." +349 ... ) as client: +350 ... # Enable metrics +351 ... await client.set_metrics_status(True) +352 ... # Check new status +353 ... is_enabled = await client.get_metrics_status() +354 """ +355 return await self._request( +356 "PUT", "metrics/enabled", json={"metricsEnabled": enabled} +357 ) +358 +359 async def get_transfer_metrics( +360 self, period: MetricsPeriod = MetricsPeriod.MONTHLY +361 ) -> Union[JsonDict, ServerMetrics]: +362 """ +363 Get transfer metrics for specified period. +364 +365 Args: +366 period: Time period for metrics (DAILY, WEEKLY, or MONTHLY) +367 +368 Returns: +369 Transfer metrics data for each access key +370 +371 Examples: +372 >>> async def doo_something(): +373 ... async with AsyncOutlineClient( +374 ... "https://example.com:1234/secret", +375 ... "ab12cd34..." +376 ... ) as client: +377 ... # Get monthly metrics +378 ... metrics = await client.get_transfer_metrics() +379 ... # Or get daily metrics +380 ... daily = await client.get_transfer_metrics(MetricsPeriod.DAILY) +381 ... for user_id, bytes_transferred in daily.bytes_transferred_by_user_id.items(): +382 ... print(f"User {user_id}: {bytes_transferred / 1024**3:.2f} GB") +383 """ +384 response = await self._request( +385 "GET", "metrics/transfer", params={"period": period.value} +386 ) +387 return await self._parse_response( +388 response, ServerMetrics, json_format=self._json_format +389 ) +390 +391 async def create_access_key( +392 self, +393 *, +394 name: Optional[str] = None, +395 password: Optional[str] = None, +396 port: Optional[int] = None, +397 method: Optional[str] = None, +398 limit: Optional[DataLimit] = None, +399 ) -> Union[JsonDict, AccessKey]: +400 """ +401 Create a new access key. +402 +403 Args: +404 name: Optional key name +405 password: Optional password +406 port: Optional port number (1-65535) +407 method: Optional encryption method +408 limit: Optional data transfer limit +409 +410 Returns: +411 New access key details +412 +413 Examples: +414 >>> async def doo_something(): +415 ... async with AsyncOutlineClient( +416 ... "https://example.com:1234/secret", +417 ... "ab12cd34..." +418 ... ) as client: +419 ... # Create basic key +420 ... key = await client.create_access_key(name="User 1") +421 ... +422 ... # Create key with data limit +423 ... _limit = DataLimit(bytes=5 * 1024**3) # 5 GB +424 ... key = await client.create_access_key( +425 ... name="Limited User", +426 ... port=8388, +427 ... limit=_limit +428 ... ) +429 ... print(f"Created key: {key.access_url}") +430 """ +431 request = AccessKeyCreateRequest( +432 name=name, password=password, port=port, method=method, limit=limit +433 ) +434 response = await self._request( +435 "POST", "access-keys", json=request.model_dump(exclude_none=True) +436 ) +437 return await self._parse_response( +438 response, AccessKey, json_format=self._json_format +439 ) +440 +441 async def get_access_keys(self) -> Union[JsonDict, AccessKeyList]: +442 """ +443 Get all access keys. +444 +445 Returns: +446 List of all access keys +447 +448 Examples: +449 >>> async def doo_something(): +450 ... async with AsyncOutlineClient( +451 ... "https://example.com:1234/secret", +452 ... "ab12cd34..." +453 ... ) as client: +454 ... keys = await client.get_access_keys() +455 ... for key in keys.access_keys: +456 ... print(f"Key {key.id}: {key.name or 'unnamed'}") +457 ... if key.data_limit: +458 ... print(f" Limit: {key.data_limit.bytes / 1024**3:.1f} GB") +459 """ +460 response = await self._request("GET", "access-keys") +461 return await self._parse_response( +462 response, AccessKeyList, json_format=self._json_format +463 ) +464 +465 async def get_access_key(self, key_id: int) -> Union[JsonDict, AccessKey]: +466 """ +467 Get specific access key. +468 +469 Args: +470 key_id: Access key ID +471 +472 Returns: +473 Access key details +474 +475 Raises: +476 APIError: If key doesn't exist +477 +478 Examples: +479 >>> async def doo_something(): +480 ... async with AsyncOutlineClient( +481 ... "https://example.com:1234/secret", +482 ... "ab12cd34..." +483 ... ) as client: +484 ... key = await client.get_access_key(1) +485 ... print(f"Port: {key.port}") +486 ... print(f"URL: {key.access_url}") +487 """ +488 response = await self._request("GET", f"access-keys/{key_id}") +489 return await self._parse_response( +490 response, AccessKey, json_format=self._json_format +491 ) +492 +493 async def rename_access_key(self, key_id: int, name: str) -> bool: +494 """ +495 Rename access key. +496 +497 Args: +498 key_id: Access key ID +499 name: New name +500 +501 Returns: +502 True if successful +503 +504 Raises: +505 APIError: If key doesn't exist +506 +507 Examples: +508 >>> async def doo_something(): +509 ... async with AsyncOutlineClient( +510 ... "https://example.com:1234/secret", +511 ... "ab12cd34..." +512 ... ) as client: +513 ... # Rename key +514 ... await client.rename_access_key(1, "Alice") +515 ... +516 ... # Verify new name +517 ... key = await client.get_access_key(1) +518 ... assert key.name == "Alice" +519 """ +520 return await self._request( +521 "PUT", f"access-keys/{key_id}/name", json={"name": name} +522 ) +523 +524 async def delete_access_key(self, key_id: int) -> bool: +525 """ +526 Delete access key. +527 +528 Args: +529 key_id: Access key ID +530 +531 Returns: +532 True if successful +533 +534 Raises: +535 APIError: If key doesn't exist +536 +537 Examples: +538 >>> async def doo_something(): +539 ... async with AsyncOutlineClient( +540 ... "https://example.com:1234/secret", +541 ... "ab12cd34..." +542 ... ) as client: +543 ... if await client.delete_access_key(1): +544 ... print("Key deleted") +545 +546 """ +547 return await self._request("DELETE", f"access-keys/{key_id}") +548 +549 async def set_access_key_data_limit(self, key_id: int, bytes_limit: int) -> bool: +550 """ +551 Set data transfer limit for access key. +552 +553 Args: +554 key_id: Access key ID +555 bytes_limit: Limit in bytes (must be positive) +556 +557 Returns: +558 True if successful +559 +560 Raises: +561 APIError: If key doesn't exist or limit is invalid +562 +563 Examples: +564 >>> async def doo_something(): +565 ... async with AsyncOutlineClient( +566 ... "https://example.com:1234/secret", +567 ... "ab12cd34..." +568 ... ) as client: +569 ... # Set 5 GB limit +570 ... limit = 5 * 1024**3 # 5 GB in bytes +571 ... await client.set_access_key_data_limit(1, limit) +572 ... +573 ... # Verify limit +574 ... key = await client.get_access_key(1) +575 ... assert key.data_limit and key.data_limit.bytes == limit +576 """ +577 return await self._request( +578 "PUT", +579 f"access-keys/{key_id}/data-limit", +580 json={"limit": {"bytes": bytes_limit}}, +581 ) +582 +583 async def remove_access_key_data_limit(self, key_id: str) -> bool: +584 """ +585 Remove data transfer limit from access key. +586 +587 Args: +588 key_id: Access key ID +589 +590 Returns: +591 True if successful +592 +593 Raises: +594 APIError: If key doesn't exist +595 """ +596 return await self._request("DELETE", f"access-keys/{key_id}/data-limit") +
Asynchronous client for the Outline VPN Server API.
+ +Arguments:
+ +-
+
- api_url: Base URL for the Outline server API +
- cert_sha256: SHA-256 fingerprint of the server's TLS certificate +
- json_format: Return raw JSON instead of Pydantic models +
- timeout: Request timeout in seconds +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... server_info = await client.get_server_info() +
56 def __init__( +57 self, +58 api_url: str, +59 cert_sha256: str, +60 *, +61 json_format: bool = True, +62 timeout: float = 30.0, +63 ) -> None: +64 self._api_url = api_url.rstrip("/") +65 self._cert_sha256 = cert_sha256 +66 self._json_format = json_format +67 self._timeout = aiohttp.ClientTimeout(total=timeout) +68 self._ssl_context = None +69 self._session: Optional[aiohttp.ClientSession] = None +70 self._in_context = False +
216 async def get_server_info(self) -> Union[JsonDict, Server]: +217 """ +218 Get server information. +219 +220 Returns: +221 Server information including name, ID, and configuration. +222 +223 Examples: +224 >>> async def doo_something(): +225 ... async with AsyncOutlineClient( +226 ... "https://example.com:1234/secret", +227 ... "ab12cd34..." +228 ... ) as client: +229 ... server = await client.get_server_info() +230 ... print(f"Server {server.name} running version {server.version}") +231 """ +232 response = await self._request("GET", "server") +233 return await self._parse_response( +234 response, Server, json_format=self._json_format +235 ) +
Get server information.
+ +Returns:
+ +++ +Server information including name, ID, and configuration.
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... server = await client.get_server_info() +... print(f"Server {server.name} running version {server.version}") +
237 async def rename_server(self, name: str) -> bool: +238 """ +239 Rename the server. +240 +241 Args: +242 name: New server name +243 +244 Returns: +245 True if successful +246 +247 Examples: +248 >>> async def doo_something(): +249 ... async with AsyncOutlineClient( +250 ... "https://example.com:1234/secret", +251 ... "ab12cd34..." +252 ... ) as client: +253 ... success = await client.rename_server("My VPN Server") +254 ... if success: +255 ... print("Server renamed successfully") +256 """ +257 return await self._request("PUT", "name", json={"name": name}) +
Rename the server.
+ +Arguments:
+ +-
+
- name: New server name +
Returns:
+ +++ +True if successful
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... success = await client.rename_server("My VPN Server") +... if success: +... print("Server renamed successfully") +
259 async def set_hostname(self, hostname: str) -> bool: +260 """ +261 Set server hostname for access keys. +262 +263 Args: +264 hostname: New hostname or IP address +265 +266 Returns: +267 True if successful +268 +269 Raises: +270 APIError: If hostname is invalid +271 +272 Examples: +273 >>> async def doo_something(): +274 ... async with AsyncOutlineClient( +275 ... "https://example.com:1234/secret", +276 ... "ab12cd34..." +277 ... ) as client: +278 ... await client.set_hostname("vpn.example.com") +279 ... # Or use IP address +280 ... await client.set_hostname("203.0.113.1") +281 """ +282 return await self._request( +283 "PUT", "server/hostname-for-access-keys", json={"hostname": hostname} +284 ) +
Set server hostname for access keys.
+ +Arguments:
+ +-
+
- hostname: New hostname or IP address +
Returns:
+ +++ +True if successful
+
Raises:
+ +-
+
- APIError: If hostname is invalid +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... await client.set_hostname("vpn.example.com") +... # Or use IP address +... await client.set_hostname("203.0.113.1") +
286 async def set_default_port(self, port: int) -> bool: +287 """ +288 Set default port for new access keys. +289 +290 Args: +291 port: Port number (1025-65535) +292 +293 Returns: +294 True if successful +295 +296 Raises: +297 APIError: If port is invalid or in use +298 +299 Examples: +300 >>> async def doo_something(): +301 ... async with AsyncOutlineClient( +302 ... "https://example.com:1234/secret", +303 ... "ab12cd34..." +304 ... ) as client: +305 ... await client.set_default_port(8388) +306 +307 """ +308 return await self._request( +309 "PUT", "server/port-for-new-access-keys", json={"port": port} +310 ) +
Set default port for new access keys.
+ +Arguments:
+ +-
+
- port: Port number (1025-65535) +
Returns:
+ +++ +True if successful
+
Raises:
+ +-
+
- APIError: If port is invalid or in use +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... await client.set_default_port(8388) +
312 async def get_metrics_status(self) -> dict[str, Any] | BaseModel: +313 """ +314 Get whether metrics collection is enabled. +315 +316 Returns: +317 Current metrics collection status +318 +319 Examples: +320 >>> async def doo_something(): +321 ... async with AsyncOutlineClient( +322 ... "https://example.com:1234/secret", +323 ... "ab12cd34..." +324 ... ) as client: +325 ... if await client.get_metrics_status(): +326 ... print("Metrics collection is enabled") +327 """ +328 response = await self._request("GET", "metrics/enabled") +329 data = await self._parse_response( +330 response, MetricsStatusResponse, json_format=self._json_format +331 ) +332 return data +
Get whether metrics collection is enabled.
+ +Returns:
+ +++ +Current metrics collection status
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... if await client.get_metrics_status(): +... print("Metrics collection is enabled") +
334 async def set_metrics_status(self, enabled: bool) -> bool: +335 """ +336 Enable or disable metrics collection. +337 +338 Args: +339 enabled: Whether to enable metrics +340 +341 Returns: +342 True if successful +343 +344 Examples: +345 >>> async def doo_something(): +346 ... async with AsyncOutlineClient( +347 ... "https://example.com:1234/secret", +348 ... "ab12cd34..." +349 ... ) as client: +350 ... # Enable metrics +351 ... await client.set_metrics_status(True) +352 ... # Check new status +353 ... is_enabled = await client.get_metrics_status() +354 """ +355 return await self._request( +356 "PUT", "metrics/enabled", json={"metricsEnabled": enabled} +357 ) +
Enable or disable metrics collection.
+ +Arguments:
+ +-
+
- enabled: Whether to enable metrics +
Returns:
+ +++ +True if successful
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... # Enable metrics +... await client.set_metrics_status(True) +... # Check new status +... is_enabled = await client.get_metrics_status() +
359 async def get_transfer_metrics( +360 self, period: MetricsPeriod = MetricsPeriod.MONTHLY +361 ) -> Union[JsonDict, ServerMetrics]: +362 """ +363 Get transfer metrics for specified period. +364 +365 Args: +366 period: Time period for metrics (DAILY, WEEKLY, or MONTHLY) +367 +368 Returns: +369 Transfer metrics data for each access key +370 +371 Examples: +372 >>> async def doo_something(): +373 ... async with AsyncOutlineClient( +374 ... "https://example.com:1234/secret", +375 ... "ab12cd34..." +376 ... ) as client: +377 ... # Get monthly metrics +378 ... metrics = await client.get_transfer_metrics() +379 ... # Or get daily metrics +380 ... daily = await client.get_transfer_metrics(MetricsPeriod.DAILY) +381 ... for user_id, bytes_transferred in daily.bytes_transferred_by_user_id.items(): +382 ... print(f"User {user_id}: {bytes_transferred / 1024**3:.2f} GB") +383 """ +384 response = await self._request( +385 "GET", "metrics/transfer", params={"period": period.value} +386 ) +387 return await self._parse_response( +388 response, ServerMetrics, json_format=self._json_format +389 ) +
Get transfer metrics for specified period.
+ +Arguments:
+ +-
+
- period: Time period for metrics (DAILY, WEEKLY, or MONTHLY) +
Returns:
+ +++ +Transfer metrics data for each access key
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... # Get monthly metrics +... metrics = await client.get_transfer_metrics() +... # Or get daily metrics +... daily = await client.get_transfer_metrics(MetricsPeriod.DAILY) +... for user_id, bytes_transferred in daily.bytes_transferred_by_user_id.items(): +... print(f"User {user_id}: {bytes_transferred / 1024**3:.2f} GB") +
391 async def create_access_key( +392 self, +393 *, +394 name: Optional[str] = None, +395 password: Optional[str] = None, +396 port: Optional[int] = None, +397 method: Optional[str] = None, +398 limit: Optional[DataLimit] = None, +399 ) -> Union[JsonDict, AccessKey]: +400 """ +401 Create a new access key. +402 +403 Args: +404 name: Optional key name +405 password: Optional password +406 port: Optional port number (1-65535) +407 method: Optional encryption method +408 limit: Optional data transfer limit +409 +410 Returns: +411 New access key details +412 +413 Examples: +414 >>> async def doo_something(): +415 ... async with AsyncOutlineClient( +416 ... "https://example.com:1234/secret", +417 ... "ab12cd34..." +418 ... ) as client: +419 ... # Create basic key +420 ... key = await client.create_access_key(name="User 1") +421 ... +422 ... # Create key with data limit +423 ... _limit = DataLimit(bytes=5 * 1024**3) # 5 GB +424 ... key = await client.create_access_key( +425 ... name="Limited User", +426 ... port=8388, +427 ... limit=_limit +428 ... ) +429 ... print(f"Created key: {key.access_url}") +430 """ +431 request = AccessKeyCreateRequest( +432 name=name, password=password, port=port, method=method, limit=limit +433 ) +434 response = await self._request( +435 "POST", "access-keys", json=request.model_dump(exclude_none=True) +436 ) +437 return await self._parse_response( +438 response, AccessKey, json_format=self._json_format +439 ) +
Create a new access key.
+ +Arguments:
+ +-
+
- name: Optional key name +
- password: Optional password +
- port: Optional port number (1-65535) +
- method: Optional encryption method +
- limit: Optional data transfer limit +
Returns:
+ +++ +New access key details
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... # Create basic key +... key = await client.create_access_key(name="User 1") +... +... # Create key with data limit +... _limit = DataLimit(bytes=5 * 1024**3) # 5 GB +... key = await client.create_access_key( +... name="Limited User", +... port=8388, +... limit=_limit +... ) +... print(f"Created key: {key.access_url}") +
441 async def get_access_keys(self) -> Union[JsonDict, AccessKeyList]: +442 """ +443 Get all access keys. +444 +445 Returns: +446 List of all access keys +447 +448 Examples: +449 >>> async def doo_something(): +450 ... async with AsyncOutlineClient( +451 ... "https://example.com:1234/secret", +452 ... "ab12cd34..." +453 ... ) as client: +454 ... keys = await client.get_access_keys() +455 ... for key in keys.access_keys: +456 ... print(f"Key {key.id}: {key.name or 'unnamed'}") +457 ... if key.data_limit: +458 ... print(f" Limit: {key.data_limit.bytes / 1024**3:.1f} GB") +459 """ +460 response = await self._request("GET", "access-keys") +461 return await self._parse_response( +462 response, AccessKeyList, json_format=self._json_format +463 ) +
Get all access keys.
+ +Returns:
+ +++ +List of all access keys
+
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... keys = await client.get_access_keys() +... for key in keys.access_keys: +... print(f"Key {key.id}: {key.name or 'unnamed'}") +... if key.data_limit: +... print(f" Limit: {key.data_limit.bytes / 1024**3:.1f} GB") +
465 async def get_access_key(self, key_id: int) -> Union[JsonDict, AccessKey]: +466 """ +467 Get specific access key. +468 +469 Args: +470 key_id: Access key ID +471 +472 Returns: +473 Access key details +474 +475 Raises: +476 APIError: If key doesn't exist +477 +478 Examples: +479 >>> async def doo_something(): +480 ... async with AsyncOutlineClient( +481 ... "https://example.com:1234/secret", +482 ... "ab12cd34..." +483 ... ) as client: +484 ... key = await client.get_access_key(1) +485 ... print(f"Port: {key.port}") +486 ... print(f"URL: {key.access_url}") +487 """ +488 response = await self._request("GET", f"access-keys/{key_id}") +489 return await self._parse_response( +490 response, AccessKey, json_format=self._json_format +491 ) +
Get specific access key.
+ +Arguments:
+ +-
+
- key_id: Access key ID +
Returns:
+ +++ +Access key details
+
Raises:
+ +-
+
- APIError: If key doesn't exist +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... key = await client.get_access_key(1) +... print(f"Port: {key.port}") +... print(f"URL: {key.access_url}") +
493 async def rename_access_key(self, key_id: int, name: str) -> bool: +494 """ +495 Rename access key. +496 +497 Args: +498 key_id: Access key ID +499 name: New name +500 +501 Returns: +502 True if successful +503 +504 Raises: +505 APIError: If key doesn't exist +506 +507 Examples: +508 >>> async def doo_something(): +509 ... async with AsyncOutlineClient( +510 ... "https://example.com:1234/secret", +511 ... "ab12cd34..." +512 ... ) as client: +513 ... # Rename key +514 ... await client.rename_access_key(1, "Alice") +515 ... +516 ... # Verify new name +517 ... key = await client.get_access_key(1) +518 ... assert key.name == "Alice" +519 """ +520 return await self._request( +521 "PUT", f"access-keys/{key_id}/name", json={"name": name} +522 ) +
Rename access key.
+ +Arguments:
+ +-
+
- key_id: Access key ID +
- name: New name +
Returns:
+ +++ +True if successful
+
Raises:
+ +-
+
- APIError: If key doesn't exist +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... # Rename key +... await client.rename_access_key(1, "Alice") +... +... # Verify new name +... key = await client.get_access_key(1) +... assert key.name == "Alice" +
524 async def delete_access_key(self, key_id: int) -> bool: +525 """ +526 Delete access key. +527 +528 Args: +529 key_id: Access key ID +530 +531 Returns: +532 True if successful +533 +534 Raises: +535 APIError: If key doesn't exist +536 +537 Examples: +538 >>> async def doo_something(): +539 ... async with AsyncOutlineClient( +540 ... "https://example.com:1234/secret", +541 ... "ab12cd34..." +542 ... ) as client: +543 ... if await client.delete_access_key(1): +544 ... print("Key deleted") +545 +546 """ +547 return await self._request("DELETE", f"access-keys/{key_id}") +
Delete access key.
+ +Arguments:
+ +-
+
- key_id: Access key ID +
Returns:
+ +++ +True if successful
+
Raises:
+ +-
+
- APIError: If key doesn't exist +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... if await client.delete_access_key(1): +... print("Key deleted") +
549 async def set_access_key_data_limit(self, key_id: int, bytes_limit: int) -> bool: +550 """ +551 Set data transfer limit for access key. +552 +553 Args: +554 key_id: Access key ID +555 bytes_limit: Limit in bytes (must be positive) +556 +557 Returns: +558 True if successful +559 +560 Raises: +561 APIError: If key doesn't exist or limit is invalid +562 +563 Examples: +564 >>> async def doo_something(): +565 ... async with AsyncOutlineClient( +566 ... "https://example.com:1234/secret", +567 ... "ab12cd34..." +568 ... ) as client: +569 ... # Set 5 GB limit +570 ... limit = 5 * 1024**3 # 5 GB in bytes +571 ... await client.set_access_key_data_limit(1, limit) +572 ... +573 ... # Verify limit +574 ... key = await client.get_access_key(1) +575 ... assert key.data_limit and key.data_limit.bytes == limit +576 """ +577 return await self._request( +578 "PUT", +579 f"access-keys/{key_id}/data-limit", +580 json={"limit": {"bytes": bytes_limit}}, +581 ) +
Set data transfer limit for access key.
+ +Arguments:
+ +-
+
- key_id: Access key ID +
- bytes_limit: Limit in bytes (must be positive) +
Returns:
+ +++ +True if successful
+
Raises:
+ +-
+
- APIError: If key doesn't exist or limit is invalid +
Examples:
+ ++++++>>> async def doo_something(): +... async with AsyncOutlineClient( +... "https://example.com:1234/secret", +... "ab12cd34..." +... ) as client: +... # Set 5 GB limit +... limit = 5 * 1024**3 # 5 GB in bytes +... await client.set_access_key_data_limit(1, limit) +... +... # Verify limit +... key = await client.get_access_key(1) +... assert key.data_limit and key.data_limit.bytes == limit +
583 async def remove_access_key_data_limit(self, key_id: str) -> bool: +584 """ +585 Remove data transfer limit from access key. +586 +587 Args: +588 key_id: Access key ID +589 +590 Returns: +591 True if successful +592 +593 Raises: +594 APIError: If key doesn't exist +595 """ +596 return await self._request("DELETE", f"access-keys/{key_id}/data-limit") +
Remove data transfer limit from access key.
+ +Arguments:
+ +-
+
- key_id: Access key ID +
Returns:
+ +++ +True if successful
+
Raises:
+ +-
+
- APIError: If key doesn't exist +
Base exception for Outline client errors.
+Raised when API requests fail.
+28class AccessKey(BaseModel): +29 """Access key details.""" +30 +31 id: int +32 name: Optional[str] = None +33 password: str +34 port: int = Field(gt=0, lt=65536) +35 method: str +36 access_url: str = Field(alias="accessUrl") +37 data_limit: Optional[DataLimit] = Field(None, alias="dataLimit") +
Access key details.
+105class AccessKeyCreateRequest(BaseModel): +106 """ +107 Request parameters for creating an access key. +108 Per OpenAPI: /access-keys POST request body +109 """ +110 +111 name: Optional[str] = None +112 method: Optional[str] = None +113 password: Optional[str] = None +114 port: Optional[int] = Field(None, gt=0, lt=65536) +115 limit: Optional[DataLimit] = None +
Request parameters for creating an access key. +Per OpenAPI: /access-keys POST request body
+40class AccessKeyList(BaseModel): +41 """List of access keys.""" +42 +43 access_keys: list[AccessKey] = Field(alias="accessKeys") +
List of access keys.
+16class DataLimit(BaseModel): +17 """Data transfer limit configuration.""" +18 +19 bytes: int = Field(gt=0) +20 +21 @field_validator("bytes") +22 def validate_bytes(cls, v: int) -> int: +23 if v < 0: +24 raise ValueError("bytes must be positive") +25 return v +
Data transfer limit configuration.
+124class ErrorResponse(BaseModel): +125 """ +126 Error response structure +127 Per OpenAPI: 404 and 400 responses +128 """ +129 +130 code: str +131 message: str +
Error response structure +Per OpenAPI: 404 and 400 responses
+79class ExperimentalMetrics(BaseModel): +80 """ +81 Experimental metrics data structure +82 Per OpenAPI: /experimental/server/metrics endpoint +83 """ +84 +85 server: list[ServerMetric] +86 access_keys: list[AccessKeyMetric] = Field(alias="accessKeys") +
Experimental metrics data structure +Per OpenAPI: /experimental/server/metrics endpoint
+8class MetricsPeriod(str, Enum): + 9 """Time periods for metrics collection.""" +10 +11 DAILY = "daily" +12 WEEKLY = "weekly" +13 MONTHLY = "monthly" +
Time periods for metrics collection.
+118class MetricsStatusResponse(BaseModel): +119 """Response for /metrics/enabled endpoint""" +120 +121 metrics_enabled: bool = Field(alias="metricsEnabled") +
Response for /metrics/enabled endpoint
+89class Server(BaseModel): + 90 """ + 91 Server information. + 92 Per OpenAPI: /server endpoint schema + 93 """ + 94 + 95 name: str + 96 server_id: str = Field(alias="serverId") + 97 metrics_enabled: bool = Field(alias="metricsEnabled") + 98 created_timestamp_ms: int = Field(alias="createdTimestampMs") + 99 version: str +100 port_for_new_access_keys: int = Field(alias="portForNewAccessKeys", gt=0, lt=65536) +101 hostname_for_access_keys: Optional[str] = Field(None, alias="hostnameForAccessKeys") +102 access_key_data_limit: Optional[DataLimit] = Field(None, alias="accessKeyDataLimit") +
Server information. +Per OpenAPI: /server endpoint schema
+46class ServerMetrics(BaseModel): +47 """ +48 Server metrics data for data transferred per access key +49 Per OpenAPI: /metrics/transfer endpoint +50 """ +51 +52 bytes_transferred_by_user_id: dict[str, int] = Field( +53 alias="bytesTransferredByUserId" +54 ) +
Server metrics data for data transferred per access key +Per OpenAPI: /metrics/transfer endpoint
+