pyoutlineapi

PyOutlineAPI: A modern, async-first Python client for the Outline VPN Server API.

Copyright (c) 2025 Denis Rozhnovskiy pytelemonbot@mail.ru All rights reserved.

This software is licensed under the MIT License.

You can find the full license text at:

https://opensource.org/licenses/MIT

Source code repository:

https://github.com/orenlab/pyoutlineapi

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

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()
AsyncOutlineClient( api_url: str, cert_sha256: str, *, json_format: bool = True, timeout: float = 30.0)
101    def __init__(
102        self,
103        api_url: str,
104        cert_sha256: str,
105        *,
106        json_format: bool = True,
107        timeout: float = 30.0,
108    ) -> None:
109        self._api_url = api_url.rstrip("/")
110        self._cert_sha256 = cert_sha256
111        self._json_format = json_format
112        self._timeout = aiohttp.ClientTimeout(total=timeout)
113        self._ssl_context: Optional[Fingerprint] = None
114        self._session: Optional[aiohttp.ClientSession] = None
async def get_server_info(self) -> Union[dict[str, Any], Server]:
249    async def get_server_info(self) -> Union[JsonDict, Server]:
250        """
251        Get server information.
252
253        Returns:
254            Server information including name, ID, and configuration.
255
256        Examples:
257            >>> async def doo_something():
258            ...     async with AsyncOutlineClient(
259            ...         "https://example.com:1234/secret",
260            ...         "ab12cd34..."
261            ...     ) as client:
262            ...         server = await client.get_server_info()
263            ...         print(f"Server {server.name} running version {server.version}")
264        """
265        response = await self._request("GET", "server")
266        return await self._parse_response(
267            response, Server, json_format=self._json_format
268        )

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}")
async def rename_server(self, name: str) -> bool:
270    async def rename_server(self, name: str) -> bool:
271        """
272        Rename the server.
273
274        Args:
275            name: New server name
276
277        Returns:
278            True if successful
279
280        Examples:
281            >>> async def doo_something():
282            ...     async with AsyncOutlineClient(
283            ...         "https://example.com:1234/secret",
284            ...         "ab12cd34..."
285            ...     ) as client:
286            ...     success = await client.rename_server("My VPN Server")
287            ...     if success:
288            ...         print("Server renamed successfully")
289        """
290        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")
async def set_hostname(self, hostname: str) -> bool:
292    async def set_hostname(self, hostname: str) -> bool:
293        """
294        Set server hostname for access keys.
295
296        Args:
297            hostname: New hostname or IP address
298
299        Returns:
300            True if successful
301
302        Raises:
303            APIError: If hostname is invalid
304
305        Examples:
306            >>> async def doo_something():
307            ...     async with AsyncOutlineClient(
308            ...         "https://example.com:1234/secret",
309            ...         "ab12cd34..."
310            ...     ) as client:
311            ...         await client.set_hostname("vpn.example.com")
312            ...         # Or use IP address
313            ...         await client.set_hostname("203.0.113.1")
314        """
315        return await self._request(
316            "PUT", "server/hostname-for-access-keys", json={"hostname": hostname}
317        )

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")
async def set_default_port(self, port: int) -> bool:
319    async def set_default_port(self, port: int) -> bool:
320        """
321        Set default port for new access keys.
322
323        Args:
324            port: Port number (1025-65535)
325
326        Returns:
327            True if successful
328
329        Raises:
330            APIError: If port is invalid or in use
331
332        Examples:
333            >>> async def doo_something():
334            ...     async with AsyncOutlineClient(
335            ...         "https://example.com:1234/secret",
336            ...         "ab12cd34..."
337            ...     ) as client:
338            ...         await client.set_default_port(8388)
339
340        """
341        if port < 1025 or port > 65535:
342            raise ValueError("Privileged ports are not allowed. Use range: 1025-65535")
343
344        return await self._request(
345            "PUT", "server/port-for-new-access-keys", json={"port": port}
346        )

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)
async def get_metrics_status(self) -> dict[str, typing.Any] | pydantic.main.BaseModel:
348    async def get_metrics_status(self) -> dict[str, Any] | BaseModel:
349        """
350        Get whether metrics collection is enabled.
351
352        Returns:
353            Current metrics collection status
354
355        Examples:
356            >>> async def doo_something():
357            ...     async with AsyncOutlineClient(
358            ...         "https://example.com:1234/secret",
359            ...         "ab12cd34..."
360            ...     ) as client:
361            ...         if await client.get_metrics_status():
362            ...             print("Metrics collection is enabled")
363        """
364        response = await self._request("GET", "metrics/enabled")
365        data = await self._parse_response(
366            response, MetricsStatusResponse, json_format=self._json_format
367        )
368        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")
async def set_metrics_status(self, enabled: bool) -> bool:
370    async def set_metrics_status(self, enabled: bool) -> bool:
371        """
372        Enable or disable metrics collection.
373
374        Args:
375            enabled: Whether to enable metrics
376
377        Returns:
378            True if successful
379
380        Examples:
381            >>> async def doo_something():
382            ...     async with AsyncOutlineClient(
383            ...         "https://example.com:1234/secret",
384            ...         "ab12cd34..."
385            ...     ) as client:
386            ...         # Enable metrics
387            ...         await client.set_metrics_status(True)
388            ...         # Check new status
389            ...         is_enabled = await client.get_metrics_status()
390        """
391        return await self._request(
392            "PUT", "metrics/enabled", json={"metricsEnabled": enabled}
393        )

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()
async def get_transfer_metrics( self, period: MetricsPeriod = <MetricsPeriod.MONTHLY: 'monthly'>) -> Union[dict[str, Any], ServerMetrics]:
395    async def get_transfer_metrics(
396        self, period: MetricsPeriod = MetricsPeriod.MONTHLY
397    ) -> Union[JsonDict, ServerMetrics]:
398        """
399        Get transfer metrics for specified period.
400
401        Args:
402            period: Time period for metrics (DAILY, WEEKLY, or MONTHLY)
403
404        Returns:
405            Transfer metrics data for each access key
406
407        Examples:
408            >>> async def doo_something():
409            ...     async with AsyncOutlineClient(
410            ...         "https://example.com:1234/secret",
411            ...         "ab12cd34..."
412            ...     ) as client:
413            ...         # Get monthly metrics
414            ...         metrics = await client.get_transfer_metrics()
415            ...         # Or get daily metrics
416            ...         daily = await client.get_transfer_metrics(MetricsPeriod.DAILY)
417            ...         for user_id, bytes_transferred in daily.bytes_transferred_by_user_id.items():
418            ...             print(f"User {user_id}: {bytes_transferred / 1024**3:.2f} GB")
419        """
420        response = await self._request(
421            "GET", "metrics/transfer", params={"period": period.value}
422        )
423        return await self._parse_response(
424            response, ServerMetrics, json_format=self._json_format
425        )

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")
async def create_access_key( self, *, name: Optional[str] = None, password: Optional[str] = None, port: Optional[int] = None, method: Optional[str] = None, limit: Optional[DataLimit] = None) -> Union[dict[str, Any], AccessKey]:
427    async def create_access_key(
428        self,
429        *,
430        name: Optional[str] = None,
431        password: Optional[str] = None,
432        port: Optional[int] = None,
433        method: Optional[str] = None,
434        limit: Optional[DataLimit] = None,
435    ) -> Union[JsonDict, AccessKey]:
436        """
437        Create a new access key.
438
439        Args:
440            name: Optional key name
441            password: Optional password
442            port: Optional port number (1-65535)
443            method: Optional encryption method
444            limit: Optional data transfer limit
445
446        Returns:
447            New access key details
448
449        Examples:
450            >>> async def doo_something():
451            ...     async with AsyncOutlineClient(
452            ...         "https://example.com:1234/secret",
453            ...         "ab12cd34..."
454            ...     ) as client:
455            ...         # Create basic key
456            ...         key = await client.create_access_key(name="User 1")
457            ...
458            ...         # Create key with data limit
459            ...         _limit = DataLimit(bytes=5 * 1024**3)  # 5 GB
460            ...         key = await client.create_access_key(
461            ...             name="Limited User",
462            ...             port=8388,
463            ...             limit=_limit
464            ...         )
465            ...         print(f"Created key: {key.access_url}")
466        """
467        request = AccessKeyCreateRequest(
468            name=name, password=password, port=port, method=method, limit=limit
469        )
470        response = await self._request(
471            "POST", "access-keys", json=request.model_dump(exclude_none=True)
472        )
473        return await self._parse_response(
474            response, AccessKey, json_format=self._json_format
475        )

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}")
async def get_access_keys(self) -> Union[dict[str, Any], AccessKeyList]:
477    async def get_access_keys(self) -> Union[JsonDict, AccessKeyList]:
478        """
479        Get all access keys.
480
481        Returns:
482            List of all access keys
483
484        Examples:
485            >>> async def doo_something():
486            ...     async with AsyncOutlineClient(
487            ...         "https://example.com:1234/secret",
488            ...         "ab12cd34..."
489            ...     ) as client:
490            ...         keys = await client.get_access_keys()
491            ...         for key in keys.access_keys:
492            ...             print(f"Key {key.id}: {key.name or 'unnamed'}")
493            ...             if key.data_limit:
494            ...                 print(f"  Limit: {key.data_limit.bytes / 1024**3:.1f} GB")
495        """
496        response = await self._request("GET", "access-keys")
497        return await self._parse_response(
498            response, AccessKeyList, json_format=self._json_format
499        )

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")
async def get_access_key( self, key_id: int) -> Union[dict[str, Any], AccessKey]:
501    async def get_access_key(self, key_id: int) -> Union[JsonDict, AccessKey]:
502        """
503        Get specific access key.
504
505        Args:
506            key_id: Access key ID
507
508        Returns:
509            Access key details
510
511        Raises:
512            APIError: If key doesn't exist
513
514        Examples:
515            >>> async def doo_something():
516            ...     async with AsyncOutlineClient(
517            ...         "https://example.com:1234/secret",
518            ...         "ab12cd34..."
519            ...     ) as client:
520            ...         key = await client.get_access_key(1)
521            ...         print(f"Port: {key.port}")
522            ...         print(f"URL: {key.access_url}")
523        """
524        response = await self._request("GET", f"access-keys/{key_id}")
525        return await self._parse_response(
526            response, AccessKey, json_format=self._json_format
527        )

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}")
async def rename_access_key(self, key_id: int, name: str) -> bool:
529    async def rename_access_key(self, key_id: int, name: str) -> bool:
530        """
531        Rename access key.
532
533        Args:
534            key_id: Access key ID
535            name: New name
536
537        Returns:
538            True if successful
539
540        Raises:
541            APIError: If key doesn't exist
542
543        Examples:
544            >>> async def doo_something():
545            ...     async with AsyncOutlineClient(
546            ...         "https://example.com:1234/secret",
547            ...         "ab12cd34..."
548            ...     ) as client:
549            ...         # Rename key
550            ...         await client.rename_access_key(1, "Alice")
551            ...
552            ...         # Verify new name
553            ...         key = await client.get_access_key(1)
554            ...         assert key.name == "Alice"
555        """
556        return await self._request(
557            "PUT", f"access-keys/{key_id}/name", json={"name": name}
558        )

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"
async def delete_access_key(self, key_id: int) -> bool:
560    async def delete_access_key(self, key_id: int) -> bool:
561        """
562        Delete access key.
563
564        Args:
565            key_id: Access key ID
566
567        Returns:
568            True if successful
569
570        Raises:
571            APIError: If key doesn't exist
572
573        Examples:
574            >>> async def doo_something():
575            ...     async with AsyncOutlineClient(
576            ...         "https://example.com:1234/secret",
577            ...         "ab12cd34..."
578            ...     ) as client:
579            ...         if await client.delete_access_key(1):
580            ...             print("Key deleted")
581
582        """
583        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")
async def set_access_key_data_limit(self, key_id: int, bytes_limit: int) -> bool:
585    async def set_access_key_data_limit(self, key_id: int, bytes_limit: int) -> bool:
586        """
587        Set data transfer limit for access key.
588
589        Args:
590            key_id: Access key ID
591            bytes_limit: Limit in bytes (must be positive)
592
593        Returns:
594            True if successful
595
596        Raises:
597            APIError: If key doesn't exist or limit is invalid
598
599        Examples:
600            >>> async def doo_something():
601            ...     async with AsyncOutlineClient(
602            ...         "https://example.com:1234/secret",
603            ...         "ab12cd34..."
604            ...     ) as client:
605            ...         # Set 5 GB limit
606            ...         limit = 5 * 1024**3  # 5 GB in bytes
607            ...         await client.set_access_key_data_limit(1, limit)
608            ...
609            ...         # Verify limit
610            ...         key = await client.get_access_key(1)
611            ...         assert key.data_limit and key.data_limit.bytes == limit
612        """
613        return await self._request(
614            "PUT",
615            f"access-keys/{key_id}/data-limit",
616            json={"limit": {"bytes": bytes_limit}},
617        )

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
async def remove_access_key_data_limit(self, key_id: int) -> bool:
619    async def remove_access_key_data_limit(self, key_id: int) -> bool:
620        """
621        Remove data transfer limit from access key.
622
623        Args:
624            key_id: Access key ID
625
626        Returns:
627            True if successful
628
629        Raises:
630            APIError: If key doesn't exist
631        """
632        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
session
634    @property
635    def session(self):
636        return self._session
class OutlineError(builtins.Exception):
58class OutlineError(Exception):
59    """Base exception for Outline client errors."""

Base exception for Outline client errors.

class APIError(pyoutlineapi.OutlineError):
62class APIError(OutlineError):
63    """Raised when API requests fail."""
64
65    def __init__(self, message: str, status_code: Optional[int] = None) -> None:
66        super().__init__(message)
67        self.status_code = status_code

Raised when API requests fail.

APIError(message: str, status_code: Optional[int] = None)
65    def __init__(self, message: str, status_code: Optional[int] = None) -> None:
66        super().__init__(message)
67        self.status_code = status_code
status_code
class AccessKey(pydantic.main.BaseModel):
42class AccessKey(BaseModel):
43    """Access key details."""
44
45    id: int
46    name: Optional[str] = None
47    password: str
48    port: int = Field(gt=0, lt=65536)
49    method: str
50    access_url: str = Field(alias="accessUrl")
51    data_limit: Optional[DataLimit] = Field(None, alias="dataLimit")

Access key details.

id: int
name: Optional[str]
password: str
port: int
method: str
access_url: str
data_limit: Optional[DataLimit]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class AccessKeyCreateRequest(pydantic.main.BaseModel):
119class AccessKeyCreateRequest(BaseModel):
120    """
121    Request parameters for creating an access key.
122    Per OpenAPI: /access-keys POST request body
123    """
124
125    name: Optional[str] = None
126    method: Optional[str] = None
127    password: Optional[str] = None
128    port: Optional[int] = Field(None, gt=0, lt=65536)
129    limit: Optional[DataLimit] = None

Request parameters for creating an access key. Per OpenAPI: /access-keys POST request body

name: Optional[str]
method: Optional[str]
password: Optional[str]
port: Optional[int]
limit: Optional[DataLimit]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class AccessKeyList(pydantic.main.BaseModel):
54class AccessKeyList(BaseModel):
55    """List of access keys."""
56
57    access_keys: list[AccessKey] = Field(alias="accessKeys")

List of access keys.

access_keys: list[AccessKey]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class DataLimit(pydantic.main.BaseModel):
30class DataLimit(BaseModel):
31    """Data transfer limit configuration."""
32
33    bytes: int = Field(gt=0)
34
35    @field_validator("bytes")
36    def validate_bytes(cls, v: int) -> int:
37        if v < 0:
38            raise ValueError("bytes must be positive")
39        return v

Data transfer limit configuration.

bytes: int
@field_validator('bytes')
def validate_bytes(cls, v: int) -> int:
35    @field_validator("bytes")
36    def validate_bytes(cls, v: int) -> int:
37        if v < 0:
38            raise ValueError("bytes must be positive")
39        return v
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ErrorResponse(pydantic.main.BaseModel):
138class ErrorResponse(BaseModel):
139    """
140    Error response structure
141    Per OpenAPI: 404 and 400 responses
142    """
143
144    code: str
145    message: str

Error response structure Per OpenAPI: 404 and 400 responses

code: str
message: str
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ExperimentalMetrics(pydantic.main.BaseModel):
 93class ExperimentalMetrics(BaseModel):
 94    """
 95    Experimental metrics data structure
 96    Per OpenAPI: /experimental/server/metrics endpoint
 97    """
 98
 99    server: list[ServerMetric]
100    access_keys: list[AccessKeyMetric] = Field(alias="accessKeys")

Experimental metrics data structure Per OpenAPI: /experimental/server/metrics endpoint

server: list[pyoutlineapi.models.ServerMetric]
access_keys: list[pyoutlineapi.models.AccessKeyMetric]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class MetricsPeriod(builtins.str, enum.Enum):
22class MetricsPeriod(str, Enum):
23    """Time periods for metrics collection."""
24
25    DAILY = "daily"
26    WEEKLY = "weekly"
27    MONTHLY = "monthly"

Time periods for metrics collection.

DAILY = <MetricsPeriod.DAILY: 'daily'>
WEEKLY = <MetricsPeriod.WEEKLY: 'weekly'>
MONTHLY = <MetricsPeriod.MONTHLY: 'monthly'>
class MetricsStatusResponse(pydantic.main.BaseModel):
132class MetricsStatusResponse(BaseModel):
133    """Response for /metrics/enabled endpoint"""
134
135    metrics_enabled: bool = Field(alias="metricsEnabled")

Response for /metrics/enabled endpoint

metrics_enabled: bool
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class Server(pydantic.main.BaseModel):
103class Server(BaseModel):
104    """
105    Server information.
106    Per OpenAPI: /server endpoint schema
107    """
108
109    name: str
110    server_id: str = Field(alias="serverId")
111    metrics_enabled: bool = Field(alias="metricsEnabled")
112    created_timestamp_ms: int = Field(alias="createdTimestampMs")
113    version: str
114    port_for_new_access_keys: int = Field(alias="portForNewAccessKeys", gt=0, lt=65536)
115    hostname_for_access_keys: Optional[str] = Field(None, alias="hostnameForAccessKeys")
116    access_key_data_limit: Optional[DataLimit] = Field(None, alias="accessKeyDataLimit")

Server information. Per OpenAPI: /server endpoint schema

name: str
server_id: str
metrics_enabled: bool
created_timestamp_ms: int
version: str
port_for_new_access_keys: int
hostname_for_access_keys: Optional[str]
access_key_data_limit: Optional[DataLimit]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ServerMetrics(pydantic.main.BaseModel):
60class ServerMetrics(BaseModel):
61    """
62    Server metrics data for data transferred per access key
63    Per OpenAPI: /metrics/transfer endpoint
64    """
65
66    bytes_transferred_by_user_id: dict[str, int] = Field(
67        alias="bytesTransferredByUserId"
68    )

Server metrics data for data transferred per access key Per OpenAPI: /metrics/transfer endpoint

bytes_transferred_by_user_id: dict[str, int]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].