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

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

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:
65    def is_empty(self) -> bool:
66        """Check if the secret is an empty string."""
67        return len(self) == 0

Check if the secret is an empty string.

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

Check if the secret string is a valid JSON string.

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

Parse the secret string as JSON.

@classmethod
def validate( cls, v: Any, info: pydantic_core.core_schema.ValidationInfo) -> SecretString:
101    @classmethod
102    def validate(
103        cls,
104        v: Any,  # noqa: ANN401  # Must allow `Any` to match Pydantic signature
105        info: ValidationInfo,
106    ) -> SecretString:
107        """Validate the input value is valid as a secret string."""
108        _ = info  # Unused
109        if not isinstance(v, str):
110            raise exc.PyAirbyteInputError(
111                message="A valid `str` or `SecretString` object is required.",
112            )
113        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):
143class SecretManager(ABC):
144    """Abstract base class for secret managers.
145
146    Secret managers are used to retrieve secrets from a secret store.
147
148    By registering a secret manager, PyAirbyte can automatically locate and
149    retrieve secrets from the secret store when needed. This allows you to
150    securely store and access sensitive information such as API keys, passwords,
151    and other credentials without hardcoding them in your code.
152
153    To create a custom secret manager, subclass this class and implement the
154    `get_secret` method. By default, the secret manager will be automatically
155    registered as a global secret source, but will not replace any existing
156    secret sources. To customize this behavior, override the `auto_register` and
157    `replace_existing` attributes in your subclass as needed.
158
159    Note: Registered secrets managers always have priority over the default
160    secret sources such as environment variables, dotenv files, and Google Colab
161    secrets. If multiple secret managers are registered, the last one registered
162    will take priority.
163    """
164
165    replace_existing = False
166    as_backup = False
167
168    def __init__(self) -> None:
169        """Instantiate the new secret manager."""
170        if not hasattr(self, "name"):
171            # Default to the class name if no name is provided
172            self.name: str = self.__class__.__name__
173
174    @abstractmethod
175    def get_secret(self, secret_name: str) -> SecretString | None:
176        """Get a named secret from the secret manager.
177
178        This method should be implemented by subclasses to retrieve secrets from
179        the secret store. If the secret is not found, the method should return `None`.
180        """
181        ...
182
183    def __str__(self) -> str:
184        """Return the name of the secret manager."""
185        return self.name
186
187    def __eq__(self, value: object) -> bool:
188        """Check if the secret manager is equal to another secret manager."""
189        if isinstance(value, SecretManager):
190            return self.name == value.name
191
192        if isinstance(value, str):
193            return self.name == value
194
195        if isinstance(value, SecretSourceEnum):
196            return self.name == str(value)
197
198        return super().__eq__(value)
199
200    def __hash__(self) -> int:
201        """Return a hash of the secret manager name.
202
203        This allows the secret manager to be used in sets and dictionaries.
204        """
205        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()
168    def __init__(self) -> None:
169        """Instantiate the new secret manager."""
170        if not hasattr(self, "name"):
171            # Default to the class name if no name is provided
172            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:
174    @abstractmethod
175    def get_secret(self, secret_name: str) -> SecretString | None:
176        """Get a named secret from the secret manager.
177
178        This method should be implemented by subclasses to retrieve secrets from
179        the secret store. If the secret is not found, the method should return `None`.
180        """
181        ...

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

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)
216    def __init__(
217        self,
218        parent: SecretManager,
219        secret_name: str,
220    ) -> None:
221        """Instantiate a new secret handle."""
222        self.parent = parent
223        self.secret_name = secret_name

Instantiate a new secret handle.

parent
secret_name
def get_value(self) -> SecretString:
225    def get_value(self) -> SecretString:
226        """Get the secret from the secret manager.
227
228        Subclasses can optionally override this method to provide a more optimized code path.
229        """
230        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:
232    def parse_json(self) -> dict:
233        """Parse the secret as JSON.
234
235        This method is a convenience method to parse the secret as JSON without
236        needing to call `get_value()` first. If the secret is not a valid JSON
237        string, a `PyAirbyteInputError` will be raised.
238        """
239        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:
241    def write_to_file(
242        self,
243        file_path: Path,
244        /,
245        *,
246        silent: bool = False,
247    ) -> None:
248        """Write the secret to a file.
249
250        If `silent` is True, the method will not print any output to the console. Otherwise,
251        the method will print a message to the console indicating the file path to which the secret
252        is being written.
253
254        This method is a convenience method that writes the secret to a file as text.
255        """
256        if not silent:
257            print(
258                f"Writing secret '{self.secret_name.split('/')[-1]}' to '{file_path.absolute()!s}'"
259            )
260
261        file_path.write_text(
262            str(self.get_value()),
263            encoding="utf-8",
264        )

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.