airbyte.secrets.base

Base classes and methods for working with secrets in PyAirbyte.

  1# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
  2"""Base classes and methods for working with secrets in PyAirbyte."""
  3
  4from __future__ import annotations
  5
  6import json
  7from abc import ABC, abstractmethod
  8from enum import Enum
  9from typing import TYPE_CHECKING, Any, cast
 10
 11from pydantic_core import CoreSchema, core_schema
 12
 13from airbyte import exceptions as exc
 14
 15
 16if TYPE_CHECKING:
 17    from pathlib import Path
 18
 19    from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler, ValidationInfo
 20    from pydantic.json_schema import JsonSchemaValue
 21
 22
 23class SecretSourceEnum(str, Enum):
 24    """Enumeration of secret sources supported by PyAirbyte."""
 25
 26    ENV = "env"
 27    DOTENV = "dotenv"
 28    GOOGLE_COLAB = "google_colab"
 29    GOOGLE_GSM = "google_gsm"  # Not enabled by default
 30
 31    PROMPT = "prompt"
 32
 33    def __str__(self) -> str:
 34        """Return the string representation of the enum value."""
 35        return self.value
 36
 37
 38class SecretString(str):  # noqa: FURB189  # Allow subclass from str instead of UserStr
 39    """A string that represents a secret.
 40
 41    This class is used to mark a string as a secret. When a secret is printed, it
 42    will be masked to prevent accidental exposure of sensitive information when debugging
 43    or when printing containing objects like dictionaries.
 44
 45    To create a secret string, simply instantiate the class with any string value:
 46
 47        ```python
 48        secret = SecretString("my_secret_password")
 49        ```
 50
 51    """
 52
 53    __slots__ = ()
 54
 55    def __repr__(self) -> str:
 56        """Override the representation of the secret string to return a masked value.
 57
 58        The secret string is always masked with `****` to prevent accidental exposure, unless
 59        explicitly converted to a string. For instance, printing a config dictionary that contains
 60        a secret will automatically mask the secret value instead of printing it in plain text.
 61
 62        However, if you explicitly convert the cast the secret as a string, such as when used
 63        in an f-string, the secret will be exposed. This is the desired behavior to allow
 64        secrets to be used in a controlled manner.
 65        """
 66        return "<SecretString: ****>"
 67
 68    def is_empty(self) -> bool:
 69        """Check if the secret is an empty string."""
 70        return len(self) == 0
 71
 72    def is_json(self) -> bool:
 73        """Check if the secret string is a valid JSON string."""
 74        try:
 75            json.loads(self)
 76        except (json.JSONDecodeError, Exception):
 77            return False
 78
 79        return True
 80
 81    def __bool__(self) -> bool:
 82        """Override the boolean value of the secret string.
 83
 84        Always returns `True` without inspecting contents.
 85        """
 86        return True
 87
 88    def parse_json(self) -> dict:
 89        """Parse the secret string as JSON."""
 90        try:
 91            return json.loads(self)
 92        except json.JSONDecodeError as ex:
 93            raise exc.PyAirbyteInputError(
 94                message="Failed to parse secret as JSON.",
 95                context={
 96                    "Message": ex.msg,
 97                    "Position": ex.pos,
 98                    "SecretString_Length": len(self),  # Debug secret blank or an unexpected format.
 99                },
100            ) from None
101
102    # Pydantic compatibility
103
104    @classmethod
105    def validate(
106        cls,
107        v: Any,  # noqa: ANN401  # Must allow `Any` to match Pydantic signature
108        info: ValidationInfo,
109    ) -> SecretString:
110        """Validate the input value is valid as a secret string."""
111        _ = info  # Unused
112        if not isinstance(v, str):
113            raise exc.PyAirbyteInputError(
114                message="A valid `str` or `SecretString` object is required.",
115            )
116        return cls(v)
117
118    @classmethod
119    def __get_pydantic_core_schema__(  # noqa: PLW3201  # Pydantic dunder
120        cls,
121        source_type: Any,  # noqa: ANN401  # Must allow `Any` to match Pydantic signature
122        handler: GetCoreSchemaHandler,
123    ) -> CoreSchema:
124        """Return a modified core schema for the secret string."""
125        return core_schema.with_info_after_validator_function(
126            function=cls.validate, schema=handler(str), field_name=handler.field_name
127        )
128
129    @classmethod
130    def __get_pydantic_json_schema__(  # noqa: PLW3201  # Pydantic dunder method
131        cls, core_schema_: core_schema.CoreSchema, handler: GetJsonSchemaHandler
132    ) -> JsonSchemaValue:
133        """Return a modified JSON schema for the secret string.
134
135        - `writeOnly=True` is the official way to prevent secrets from being exposed inadvertently.
136        - `Format=password` is a popular and readable convention to indicate the field is sensitive.
137        """
138        _ = core_schema_, handler  # Unused
139        return {
140            "type": "string",
141            "format": "password",
142            "writeOnly": True,
143        }
144
145
146class SecretManager(ABC):
147    """Abstract base class for secret managers.
148
149    Secret managers are used to retrieve secrets from a secret store.
150
151    By registering a secret manager, PyAirbyte can automatically locate and
152    retrieve secrets from the secret store when needed. This allows you to
153    securely store and access sensitive information such as API keys, passwords,
154    and other credentials without hardcoding them in your code.
155
156    To create a custom secret manager, subclass this class and implement the
157    `get_secret` method. By default, the secret manager will be automatically
158    registered as a global secret source, but will not replace any existing
159    secret sources. To customize this behavior, override the `auto_register` and
160    `replace_existing` attributes in your subclass as needed.
161
162    Note: Registered secrets managers always have priority over the default
163    secret sources such as environment variables, dotenv files, and Google Colab
164    secrets. If multiple secret managers are registered, the last one registered
165    will take priority.
166    """
167
168    replace_existing = False
169    as_backup = False
170
171    def __init__(self) -> None:
172        """Instantiate the new secret manager."""
173        if not hasattr(self, "name"):
174            # Default to the class name if no name is provided
175            self.name: str = self.__class__.__name__
176
177    @abstractmethod
178    def get_secret(self, secret_name: str) -> SecretString | None:
179        """Get a named secret from the secret manager.
180
181        This method should be implemented by subclasses to retrieve secrets from
182        the secret store. If the secret is not found, the method should return `None`.
183        """
184        ...
185
186    def __str__(self) -> str:
187        """Return the name of the secret manager."""
188        return self.name
189
190    def __eq__(self, value: object) -> bool:
191        """Check if the secret manager is equal to another secret manager."""
192        if isinstance(value, SecretManager):
193            return self.name == value.name
194
195        if isinstance(value, str):
196            return self.name == value
197
198        if isinstance(value, SecretSourceEnum):
199            return self.name == str(value)
200
201        return super().__eq__(value)
202
203    def __hash__(self) -> int:
204        """Return a hash of the secret manager name.
205
206        This allows the secret manager to be used in sets and dictionaries.
207        """
208        return hash(self.name)
209
210
211class SecretHandle:
212    """A handle for a secret in a secret manager.
213
214    This class is used to store a reference to a secret in a secret manager.
215    The secret is not retrieved until the `get_value()` or `parse_json()` methods are
216    called.
217    """
218
219    def __init__(
220        self,
221        parent: SecretManager,
222        secret_name: str,
223    ) -> None:
224        """Instantiate a new secret handle."""
225        self.parent = parent
226        self.secret_name = secret_name
227
228    def get_value(self) -> SecretString:
229        """Get the secret from the secret manager.
230
231        Subclasses can optionally override this method to provide a more optimized code path.
232        """
233        return cast("SecretString", self.parent.get_secret(self.secret_name))
234
235    def parse_json(self) -> dict:
236        """Parse the secret as JSON.
237
238        This method is a convenience method to parse the secret as JSON without
239        needing to call `get_value()` first. If the secret is not a valid JSON
240        string, a `PyAirbyteInputError` will be raised.
241        """
242        return self.get_value().parse_json()
243
244    def write_to_file(
245        self,
246        file_path: Path,
247        /,
248        *,
249        silent: bool = False,
250    ) -> None:
251        """Write the secret to a file.
252
253        If `silent` is True, the method will not print any output to the console. Otherwise,
254        the method will print a message to the console indicating the file path to which the secret
255        is being written.
256
257        This method is a convenience method that writes the secret to a file as text.
258        """
259        if not silent:
260            print(
261                f"Writing secret '{self.secret_name.split('/')[-1]}' to '{file_path.absolute()!s}'"
262            )
263
264        file_path.write_text(
265            str(self.get_value()),
266            encoding="utf-8",
267        )
class SecretSourceEnum(builtins.str, enum.Enum):
24class SecretSourceEnum(str, Enum):
25    """Enumeration of secret sources supported by PyAirbyte."""
26
27    ENV = "env"
28    DOTENV = "dotenv"
29    GOOGLE_COLAB = "google_colab"
30    GOOGLE_GSM = "google_gsm"  # Not enabled by default
31
32    PROMPT = "prompt"
33
34    def __str__(self) -> str:
35        """Return the string representation of the enum value."""
36        return self.value

Enumeration of secret sources supported by PyAirbyte.

ENV = <SecretSourceEnum.ENV: 'env'>
DOTENV = <SecretSourceEnum.DOTENV: 'dotenv'>
GOOGLE_COLAB = <SecretSourceEnum.GOOGLE_COLAB: 'google_colab'>
GOOGLE_GSM = <SecretSourceEnum.GOOGLE_GSM: 'google_gsm'>
PROMPT = <SecretSourceEnum.PROMPT: 'prompt'>
Inherited Members
enum.Enum
name
value
builtins.str
encode
replace
split
rsplit
join
capitalize
casefold
title
center
count
expandtabs
find
partition
index
ljust
lower
lstrip
rfind
rindex
rjust
rstrip
rpartition
splitlines
strip
swapcase
translate
upper
startswith
endswith
removeprefix
removesuffix
isascii
islower
isupper
istitle
isspace
isdecimal
isdigit
isnumeric
isalpha
isalnum
isidentifier
isprintable
zfill
format
format_map
maketrans
class SecretString(builtins.str):
 39class SecretString(str):  # noqa: FURB189  # Allow subclass from str instead of UserStr
 40    """A string that represents a secret.
 41
 42    This class is used to mark a string as a secret. When a secret is printed, it
 43    will be masked to prevent accidental exposure of sensitive information when debugging
 44    or when printing containing objects like dictionaries.
 45
 46    To create a secret string, simply instantiate the class with any string value:
 47
 48        ```python
 49        secret = SecretString("my_secret_password")
 50        ```
 51
 52    """
 53
 54    __slots__ = ()
 55
 56    def __repr__(self) -> str:
 57        """Override the representation of the secret string to return a masked value.
 58
 59        The secret string is always masked with `****` to prevent accidental exposure, unless
 60        explicitly converted to a string. For instance, printing a config dictionary that contains
 61        a secret will automatically mask the secret value instead of printing it in plain text.
 62
 63        However, if you explicitly convert the cast the secret as a string, such as when used
 64        in an f-string, the secret will be exposed. This is the desired behavior to allow
 65        secrets to be used in a controlled manner.
 66        """
 67        return "<SecretString: ****>"
 68
 69    def is_empty(self) -> bool:
 70        """Check if the secret is an empty string."""
 71        return len(self) == 0
 72
 73    def is_json(self) -> bool:
 74        """Check if the secret string is a valid JSON string."""
 75        try:
 76            json.loads(self)
 77        except (json.JSONDecodeError, Exception):
 78            return False
 79
 80        return True
 81
 82    def __bool__(self) -> bool:
 83        """Override the boolean value of the secret string.
 84
 85        Always returns `True` without inspecting contents.
 86        """
 87        return True
 88
 89    def parse_json(self) -> dict:
 90        """Parse the secret string as JSON."""
 91        try:
 92            return json.loads(self)
 93        except json.JSONDecodeError as ex:
 94            raise exc.PyAirbyteInputError(
 95                message="Failed to parse secret as JSON.",
 96                context={
 97                    "Message": ex.msg,
 98                    "Position": ex.pos,
 99                    "SecretString_Length": len(self),  # Debug secret blank or an unexpected format.
100                },
101            ) from None
102
103    # Pydantic compatibility
104
105    @classmethod
106    def validate(
107        cls,
108        v: Any,  # noqa: ANN401  # Must allow `Any` to match Pydantic signature
109        info: ValidationInfo,
110    ) -> SecretString:
111        """Validate the input value is valid as a secret string."""
112        _ = info  # Unused
113        if not isinstance(v, str):
114            raise exc.PyAirbyteInputError(
115                message="A valid `str` or `SecretString` object is required.",
116            )
117        return cls(v)
118
119    @classmethod
120    def __get_pydantic_core_schema__(  # noqa: PLW3201  # Pydantic dunder
121        cls,
122        source_type: Any,  # noqa: ANN401  # Must allow `Any` to match Pydantic signature
123        handler: GetCoreSchemaHandler,
124    ) -> CoreSchema:
125        """Return a modified core schema for the secret string."""
126        return core_schema.with_info_after_validator_function(
127            function=cls.validate, schema=handler(str), field_name=handler.field_name
128        )
129
130    @classmethod
131    def __get_pydantic_json_schema__(  # noqa: PLW3201  # Pydantic dunder method
132        cls, core_schema_: core_schema.CoreSchema, handler: GetJsonSchemaHandler
133    ) -> JsonSchemaValue:
134        """Return a modified JSON schema for the secret string.
135
136        - `writeOnly=True` is the official way to prevent secrets from being exposed inadvertently.
137        - `Format=password` is a popular and readable convention to indicate the field is sensitive.
138        """
139        _ = core_schema_, handler  # Unused
140        return {
141            "type": "string",
142            "format": "password",
143            "writeOnly": True,
144        }

A string that represents a secret.

This class is used to mark a string as a secret. When a secret is printed, it will be masked to prevent accidental exposure of sensitive information when debugging or when printing containing objects like dictionaries.

To create a secret string, simply instantiate the class with any string value:

secret = SecretString("my_secret_password")
def is_empty(self) -> bool:
69    def is_empty(self) -> bool:
70        """Check if the secret is an empty string."""
71        return len(self) == 0

Check if the secret is an empty string.

def is_json(self) -> bool:
73    def is_json(self) -> bool:
74        """Check if the secret string is a valid JSON string."""
75        try:
76            json.loads(self)
77        except (json.JSONDecodeError, Exception):
78            return False
79
80        return True

Check if the secret string is a valid JSON string.

def parse_json(self) -> dict:
 89    def parse_json(self) -> dict:
 90        """Parse the secret string as JSON."""
 91        try:
 92            return json.loads(self)
 93        except json.JSONDecodeError as ex:
 94            raise exc.PyAirbyteInputError(
 95                message="Failed to parse secret as JSON.",
 96                context={
 97                    "Message": ex.msg,
 98                    "Position": ex.pos,
 99                    "SecretString_Length": len(self),  # Debug secret blank or an unexpected format.
100                },
101            ) from None

Parse the secret string as JSON.

@classmethod
def validate( cls, v: Any, info: pydantic_core.core_schema.ValidationInfo) -> SecretString:
105    @classmethod
106    def validate(
107        cls,
108        v: Any,  # noqa: ANN401  # Must allow `Any` to match Pydantic signature
109        info: ValidationInfo,
110    ) -> SecretString:
111        """Validate the input value is valid as a secret string."""
112        _ = info  # Unused
113        if not isinstance(v, str):
114            raise exc.PyAirbyteInputError(
115                message="A valid `str` or `SecretString` object is required.",
116            )
117        return cls(v)

Validate the input value is valid as a secret string.

Inherited Members
builtins.str
encode
replace
split
rsplit
join
capitalize
casefold
title
center
count
expandtabs
find
partition
index
ljust
lower
lstrip
rfind
rindex
rjust
rstrip
rpartition
splitlines
strip
swapcase
translate
upper
startswith
endswith
removeprefix
removesuffix
isascii
islower
isupper
istitle
isspace
isdecimal
isdigit
isnumeric
isalpha
isalnum
isidentifier
isprintable
zfill
format
format_map
maketrans
class SecretManager(abc.ABC):
147class SecretManager(ABC):
148    """Abstract base class for secret managers.
149
150    Secret managers are used to retrieve secrets from a secret store.
151
152    By registering a secret manager, PyAirbyte can automatically locate and
153    retrieve secrets from the secret store when needed. This allows you to
154    securely store and access sensitive information such as API keys, passwords,
155    and other credentials without hardcoding them in your code.
156
157    To create a custom secret manager, subclass this class and implement the
158    `get_secret` method. By default, the secret manager will be automatically
159    registered as a global secret source, but will not replace any existing
160    secret sources. To customize this behavior, override the `auto_register` and
161    `replace_existing` attributes in your subclass as needed.
162
163    Note: Registered secrets managers always have priority over the default
164    secret sources such as environment variables, dotenv files, and Google Colab
165    secrets. If multiple secret managers are registered, the last one registered
166    will take priority.
167    """
168
169    replace_existing = False
170    as_backup = False
171
172    def __init__(self) -> None:
173        """Instantiate the new secret manager."""
174        if not hasattr(self, "name"):
175            # Default to the class name if no name is provided
176            self.name: str = self.__class__.__name__
177
178    @abstractmethod
179    def get_secret(self, secret_name: str) -> SecretString | None:
180        """Get a named secret from the secret manager.
181
182        This method should be implemented by subclasses to retrieve secrets from
183        the secret store. If the secret is not found, the method should return `None`.
184        """
185        ...
186
187    def __str__(self) -> str:
188        """Return the name of the secret manager."""
189        return self.name
190
191    def __eq__(self, value: object) -> bool:
192        """Check if the secret manager is equal to another secret manager."""
193        if isinstance(value, SecretManager):
194            return self.name == value.name
195
196        if isinstance(value, str):
197            return self.name == value
198
199        if isinstance(value, SecretSourceEnum):
200            return self.name == str(value)
201
202        return super().__eq__(value)
203
204    def __hash__(self) -> int:
205        """Return a hash of the secret manager name.
206
207        This allows the secret manager to be used in sets and dictionaries.
208        """
209        return hash(self.name)

Abstract base class for secret managers.

Secret managers are used to retrieve secrets from a secret store.

By registering a secret manager, PyAirbyte can automatically locate and retrieve secrets from the secret store when needed. This allows you to securely store and access sensitive information such as API keys, passwords, and other credentials without hardcoding them in your code.

To create a custom secret manager, subclass this class and implement the get_secret method. By default, the secret manager will be automatically registered as a global secret source, but will not replace any existing secret sources. To customize this behavior, override the auto_register and replace_existing attributes in your subclass as needed.

Note: Registered secrets managers always have priority over the default secret sources such as environment variables, dotenv files, and Google Colab secrets. If multiple secret managers are registered, the last one registered will take priority.

SecretManager()
172    def __init__(self) -> None:
173        """Instantiate the new secret manager."""
174        if not hasattr(self, "name"):
175            # Default to the class name if no name is provided
176            self.name: str = self.__class__.__name__

Instantiate the new secret manager.

replace_existing = False
as_backup = False
@abstractmethod
def get_secret(self, secret_name: str) -> SecretString | None:
178    @abstractmethod
179    def get_secret(self, secret_name: str) -> SecretString | None:
180        """Get a named secret from the secret manager.
181
182        This method should be implemented by subclasses to retrieve secrets from
183        the secret store. If the secret is not found, the method should return `None`.
184        """
185        ...

Get a named secret from the secret manager.

This method should be implemented by subclasses to retrieve secrets from the secret store. If the secret is not found, the method should return None.

class SecretHandle:
212class SecretHandle:
213    """A handle for a secret in a secret manager.
214
215    This class is used to store a reference to a secret in a secret manager.
216    The secret is not retrieved until the `get_value()` or `parse_json()` methods are
217    called.
218    """
219
220    def __init__(
221        self,
222        parent: SecretManager,
223        secret_name: str,
224    ) -> None:
225        """Instantiate a new secret handle."""
226        self.parent = parent
227        self.secret_name = secret_name
228
229    def get_value(self) -> SecretString:
230        """Get the secret from the secret manager.
231
232        Subclasses can optionally override this method to provide a more optimized code path.
233        """
234        return cast("SecretString", self.parent.get_secret(self.secret_name))
235
236    def parse_json(self) -> dict:
237        """Parse the secret as JSON.
238
239        This method is a convenience method to parse the secret as JSON without
240        needing to call `get_value()` first. If the secret is not a valid JSON
241        string, a `PyAirbyteInputError` will be raised.
242        """
243        return self.get_value().parse_json()
244
245    def write_to_file(
246        self,
247        file_path: Path,
248        /,
249        *,
250        silent: bool = False,
251    ) -> None:
252        """Write the secret to a file.
253
254        If `silent` is True, the method will not print any output to the console. Otherwise,
255        the method will print a message to the console indicating the file path to which the secret
256        is being written.
257
258        This method is a convenience method that writes the secret to a file as text.
259        """
260        if not silent:
261            print(
262                f"Writing secret '{self.secret_name.split('/')[-1]}' to '{file_path.absolute()!s}'"
263            )
264
265        file_path.write_text(
266            str(self.get_value()),
267            encoding="utf-8",
268        )

A handle for a secret in a secret manager.

This class is used to store a reference to a secret in a secret manager. The secret is not retrieved until the get_value() or parse_json() methods are called.

SecretHandle(parent: SecretManager, secret_name: str)
220    def __init__(
221        self,
222        parent: SecretManager,
223        secret_name: str,
224    ) -> None:
225        """Instantiate a new secret handle."""
226        self.parent = parent
227        self.secret_name = secret_name

Instantiate a new secret handle.

parent
secret_name
def get_value(self) -> SecretString:
229    def get_value(self) -> SecretString:
230        """Get the secret from the secret manager.
231
232        Subclasses can optionally override this method to provide a more optimized code path.
233        """
234        return cast("SecretString", self.parent.get_secret(self.secret_name))

Get the secret from the secret manager.

Subclasses can optionally override this method to provide a more optimized code path.

def parse_json(self) -> dict:
236    def parse_json(self) -> dict:
237        """Parse the secret as JSON.
238
239        This method is a convenience method to parse the secret as JSON without
240        needing to call `get_value()` first. If the secret is not a valid JSON
241        string, a `PyAirbyteInputError` will be raised.
242        """
243        return self.get_value().parse_json()

Parse the secret as JSON.

This method is a convenience method to parse the secret as JSON without needing to call get_value() first. If the secret is not a valid JSON string, a PyAirbyteInputError will be raised.

def write_to_file(self, file_path: pathlib.Path, /, *, silent: bool = False) -> None:
245    def write_to_file(
246        self,
247        file_path: Path,
248        /,
249        *,
250        silent: bool = False,
251    ) -> None:
252        """Write the secret to a file.
253
254        If `silent` is True, the method will not print any output to the console. Otherwise,
255        the method will print a message to the console indicating the file path to which the secret
256        is being written.
257
258        This method is a convenience method that writes the secret to a file as text.
259        """
260        if not silent:
261            print(
262                f"Writing secret '{self.secret_name.split('/')[-1]}' to '{file_path.absolute()!s}'"
263            )
264
265        file_path.write_text(
266            str(self.get_value()),
267            encoding="utf-8",
268        )

Write the secret to a file.

If silent is True, the method will not print any output to the console. Otherwise, the method will print a message to the console indicating the file path to which the secret is being written.

This method is a convenience method that writes the secret to a file as text.