Release v1.0.0

This commit is contained in:
Tomek Wójcik 2024-01-04 20:30:54 +01:00
commit 19c8d10645
45 changed files with 3745 additions and 0 deletions

166
.gitignore vendored Normal file
View File

@ -0,0 +1,166 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# direnv
.envrc*
# ops stuff
ops/

5
CHANGELOG.md Normal file
View File

@ -0,0 +1,5 @@
## Keep It Secret Changelog
#### v1.0.0 (2024-01-04)
* Initial public release.

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2023-present Tomek Wójcik <contact@bthlabs.pl>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

88
README.md Normal file
View File

@ -0,0 +1,88 @@
# Keep It Secret by BTHLabs
*Keep It Secret* is a small Python library for declarative management
of app secrets.
[Docs](https://projects.bthlabs.pl/keep-it-secret/) | [Source repository](https://git.bthlabs.pl/tomekwojcik/keep-it-secret/)
## Installation
```
$ pip install keep_it_secret
```
## Usage
*Keep It Secret* gives a developer API needed to declare secrets used
by the app and access them in a secure, uniform manner.
Consider the following example:
```
from secrets_manager import (
AbstractField, EnvField, LiteralField, Secrets, SecretsField,
)
from secrets_manager.ext.aws import AWSSecrets, AWSSecretsManagerField
class AppSecrets(Secrets):
secret_key: str = AbstractField.new()
db_password: str = EnvField.new('APP_DB_PASSWORD', required=True)
pbkdf2_iterations_count: int = EnvField(
'APP_PBKDF2_ITERATIONS_COUNT',
default=16384,
required=False,
as_type=int,
)
class DevelopmentSecrets(AppSecrets):
secret_key: str = LiteralField.new('thisisntsecure')
class ProductionSecrets(AppSecrets):
aws: AWSSecrets = SecretsField.new(AWSSecrets)
secret_key: str = AWSSecretsManagerField(
'app/production/secret_key', required=True,
)
db_password: str = AWSSecretsManagerField(
'app/production/db_password', required=True,
)
```
The `AppSecrets` class serves as base class for environment specific classes.
The environment specific classes can overload any field, add new fields and
extend the base class to provide custom behaviour.
The `DevelopmentSecrets` class uses environment variables and literal values
to provide secrets suitable for the development environment:
```
>>> development_secrets = DevelopmentSecrets()
>>> development_secrets.secret_key
'thisisntsecure'
>>> development_secrets.db_password
'spam'
>>> development_secrets.pbkdf2_iterations_count
1024
```
The `ProductionSecrets` class uses environment variables and AWS Secrets
Manager to provide secrets suitable for the development environment:
```
>>> production_secrets = ProductionSecrets()
>>> production_secrets.aws.access_key_id
'anawsaccesskey'
>>> production_secrets.secret_key
'asecuresecretkey'
>>> production_secrets.db_password
'asecuredbpassword'
>>> production_secrets.pbkdf2_iterations_count
16384
```
## Author
*Keep It Secret* is developed by [Tomek Wójcik](https://www.bthlabs.pl/).
## License
*Keep It Secret* is licensed under the MIT License.

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

36
docs/source/api.rst Normal file
View File

@ -0,0 +1,36 @@
API Documentation
=================
.. module:: keep_it_secret
This section provides the API documentation for Keep It Secret.
The Secrets Class
-----------------
.. autoclass:: Secrets
:members:
Base Field
----------
.. autoclass:: Field
:members:
:special-members: __call__
Concrete Fields
---------------
.. autoclass:: AbstractField
:members:
.. autoclass:: EnvField
:members:
.. autoclass:: LiteralField
:members:
.. autoclass:: SecretsField
:members:

34
docs/source/conf.py Normal file
View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# type: ignore
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Keep It Secret'
copyright = '2023-present Tomek Wójcik'
author = 'Tomek Wójcik'
version = '1.0.0'
# The full version, including alpha/beta/rc tags
release = '1.0.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
'sphinx.ext.autodoc',
]
templates_path = ['_templates']
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']

31
docs/source/ext.rst Normal file
View File

@ -0,0 +1,31 @@
Extensions
==========
.. module:: keep_it_secret.ext
This section provides documentation for built-in extensions for Keep It Secret.
AWS Secrets Manager Wrapper
---------------------------
**Installation**
Since AWS extension has external dependencies it needs to be explicitly named
to be installed:
.. code-block:: shell
$ pip install keep_it_secret[aws]
**API**
.. autoclass:: keep_it_secret.ext.aws.AWSSecrets
:members:
.. autoclass:: keep_it_secret.ext.aws.AWSSecretsManagerField
:members:
Basic secrets loader
--------------------
.. autofunction:: keep_it_secret.ext.loader.load_secrets

16
docs/source/index.rst Normal file
View File

@ -0,0 +1,16 @@
Keep It Secret by BTHLabs
=========================
Keep It Secret is a small Python library for declarative management
of app secrets.
.. toctree::
:maxdepth: 2
overview
.. toctree::
:maxdepth: 2
api
ext

80
docs/source/overview.rst Normal file
View File

@ -0,0 +1,80 @@
Overview
========
This section provides the general overview of Keep It Secret.
Installation
------------
.. code-block:: shell
$ pip install keep_it_secret
Usage
-----
Keep It Secret gives a developer API needed to declare secrets used
by the app and access them in a secure, uniform manner.
Consider the following example:
.. code-block:: python
from secrets_manager import (
AbstractField, EnvField, LiteralField, Secrets, SecretsField,
)
from secrets_manager.ext.aws import AWSSecrets, AWSSecretsManagerField
class AppSecrets(Secrets):
secret_key: str = AbstractField.new()
db_password: str = EnvField.new('APP_DB_PASSWORD', required=True)
pbkdf2_iterations_count: int = EnvField(
'APP_PBKDF2_ITERATIONS_COUNT',
default=16384,
required=False,
as_type=int,
)
class DevelopmentSecrets(AppSecrets):
secret_key: str = LiteralField.new('thisisntsecure')
class ProductionSecrets(AppSecrets):
aws: AWSSecrets = SecretsField.new(AWSSecrets)
secret_key: str = AWSSecretsManagerField(
'app/production/secret_key', required=True,
)
db_password: str = AWSSecretsManagerField(
'app/production/db_password', required=True,
)
The ``AppSecrets`` class serves as base class for environment specific classes.
The environment specific classes can overload any field, add new fields and
extend the base class to provide custom behaviour.
The ``DevelopmentSecrets`` class uses environment variables and literal values
to provide secrets suitable for the development environment:
.. code-block:: pycon
>>> development_secrets = DevelopmentSecrets()
>>> development_secrets.secret_key
'thisisntsecure'
>>> development_secrets.db_password
'spam'
>>> development_secrets.pbkdf2_iterations_count
1024
The ``ProductionSecrets`` class uses environment variables and AWS Secrets
Manager to provide secrets suitable for the development environment:
.. code-block:: pycon
>>> production_secrets = ProductionSecrets()
>>> production_secrets.aws.access_key_id
'anawsaccesskey'
>>> production_secrets.secret_key
'asecuresecretkey'
>>> production_secrets.db_password
'asecuredbpassword'
>>> production_secrets.pbkdf2_iterations_count
16384

3
invoke.yaml Normal file
View File

@ -0,0 +1,3 @@
run:
echo: true
pty: true

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from .fields import ( # noqa: F401
AbstractField, EnvField, Field, LiteralField, SecretsField,
)
from .secrets import Secrets # noqa: F401
__version__ = '1.0.0'
__all__ = [
'AbstractField',
'EnvField',
'Field',
'LiteralField',
'SecretsField',
'Secrets',
]

View File

149
keep_it_secret/ext/aws.py Normal file
View File

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import typing
import boto3
from keep_it_secret.fields import EnvField, Field
from keep_it_secret.secrets import Secrets
class PAWSSecretsManagerClient(typing.Protocol):
def get_secret_value(self,
*,
SecretId: str,
VersionString: str | None = None,
VersionStage: str | None = None) -> typing.Any:
...
class AWSSecrets(Secrets):
"""
Concrete :py:class:`keep_it_secret.Secrets` subclass that maps environment
variables to AWS credentials.
"""
access_key_id: str | None = EnvField.new('AWS_ACCESS_KEY_ID', required=False)
"""
Maps ``AWS_ACCESS_KEY_ID`` environment variable. Optional, defaults to
``None``.
:type: ``str | None``
"""
secret_access_key: str | None = EnvField.new('AWS_SECRET_ACCESS_KEY', required=False)
"""
Maps ``AWS_SECRET_ACCESS_KEY`` environment variable. Optional, defaults to
``None``.
:type: ``str | None``
"""
session_token: str | None = EnvField.new('AWS_SESSION_TOKEN', required=False)
"""
Maps ``AWS_SESSION_TOKEN`` environment variable. Optional, defaults to
``None``.
:type: ``str | None``
"""
region_name: str | None = EnvField.new('AWS_DEFAULT_REGION', required=False)
"""
Maps ``AWS_DEFAULT_REGION`` environment variable. Optional, defaults to
``None``.
:type: ``str | None``
"""
def as_boto3_client_kwargs(self) -> dict[str, typing.Any]:
"""
Return representation of the mapped variables for use in
``boto3.client()`` call.
"""
result = {}
if self.access_key_id is not None:
result['aws_access_key_id'] = self.access_key_id
if self.secret_access_key is not None:
result['aws_secret_access_key'] = self.secret_access_key
if self.session_token is not None:
result['aws_session_token'] = self.session_token
if self.region_name is not None:
result['region_name'] = self.region_name
return result
class AWSSecretsManagerField(Field):
"""
Concrete :py:class:`keep_it_secret.Field` subclass that uses AWS Secrets
Manager to resolve the value.
:param secret_id: ID of the secret to fetch.
:param default: Default value. Defaults to ``None``.
:param decoder: A callable to decode the fetched value. Defaults to
``json.loads``.
"""
def __init__(self,
secret_id: str,
default: typing.Any = None,
decoder: typing.Callable = json.loads,
**field_options):
field_options['as_type'] = None
super().__init__(**field_options)
self.secret_id = secret_id
self.default = default
self.decoder = decoder
self.client: PAWSSecretsManagerClient | None = None
@classmethod
def new(cls: type[AWSSecretsManagerField], # type: ignore[override]
secret_id: str,
default: typing.Any = None,
decoder: typing.Callable = json.loads,
**field_options) -> AWSSecretsManagerField:
return cls(secret_id, default=default, decoder=decoder, **field_options)
def get_client(self, secrets: Secrets) -> PAWSSecretsManagerClient:
if self.client is None:
aws_secrets = typing.cast(AWSSecrets, secrets.aws) # type: ignore[attr-defined]
self.client = boto3.client(
'secretsmanager',
**aws_secrets.as_boto3_client_kwargs(),
)
return self.client
def get_value(self, secrets: Secrets) -> typing.Any:
"""
Retrieve, decode and return the secret specified by *secret_id*.
Depends on :py:class:`AWSSecrets` to be declared in ``secrets.aws``
field.
:raises DependencyMissing: Signal that ``secrets.aws`` field is
missing.
:raises RequiredValueMissing: Signal the field's value is required but
*secret_id* is not present in the Secrets Manager.
"""
if hasattr(secrets, 'aws') is False:
raise self.DependencyMissing('aws')
client = self.get_client(secrets)
try:
secret = client.get_secret_value(SecretId=self.secret_id)
return self.decoder(secret['SecretString'])
except client.exceptions.ResourceNotFoundException as exception: # type: ignore[attr-defined]
if self.required is True:
raise self.RequiredValueMissing(self.secret_id) from exception
else:
return self.default

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import importlib
import typing
def load_secrets(package: str, env: str, app: str) -> typing.Any:
"""
A basic secrets loader. Will attempt to import the secrets module and
return the ``__secrets__`` attribute.
:param package: The package which contains the module.
:param env: Environment identifier (e.g. ``development``).
:param app: Application identifier (e.g. ``weather_service``).
"""
secrets_module = importlib.import_module(f'{package}.{env}.{app}')
return secrets_module.__secrets__

287
keep_it_secret/fields.py Normal file
View File

@ -0,0 +1,287 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import abc
import os
import typing
if typing.TYPE_CHECKING:
from keep_it_secret.secrets import Secrets
class Field(abc.ABC):
"""
Base class for fields.
Example:
.. code-block:: python
class SpamField(Field):
def get_value(self, secrets):
return 'spam'
class AppSecrets(Secrets):
spam: str = SpamField.new()
Normal instantiation and using the ``new()`` factory method are
functionally equal. The ``new()`` method is provided for compatibility
with type annotations.
.. code-block:: pycon
>>> spam_one = SpamField.new()
>>> spam_two = SpamField()
>>> spam_one(secrets) == spam_two(secrets)
True
The field is evaluated when its instance is called. This is done during
initialization of the :py:class:`keep_it_secret.Secrets` subclass in which
the field was used. The result of this is either the value cast to
``as_type`` (if specified) or ``None``.
Note that the base class doesn't enforce the ``required`` flag. Its
behaviour is implementation specific.
:param as_type: Type to cast the value to. If ``None``, no casting will be
done. Defaults to ``str``.
:param required: Required flag. Defaults to ``True``.
:param description: Human readable description. Defaults to ``None``.
"""
class KeepItSecretFieldError(Exception):
"""Base class for field exceptions."""
pass
class RequiredValueMissing(KeepItSecretFieldError):
"""Raised when the field's value is required but missing."""
pass
class DependencyMissing(KeepItSecretFieldError):
"""Raised when the field depends on another, which isn't defined."""
pass
def __init__(self, *, as_type: type | None = str, required: bool = True, description: str | None = None):
self.as_type = as_type
self.required = required
self.description = description
self.name = str(self)
@classmethod
def new(cls: type[Field], **field_options) -> typing.Any:
"""
The field factory. Constructs the field in a manner which is compatible
with type annotations.
Positional arguments, keyword arguments and *field_options* are passed
to the constructor.
"""
return cls(**field_options)
def __call__(self, secrets: Secrets) -> typing.Any:
"""Evaluate the field and return the value."""
value = self.get_value(secrets)
if value is None:
return None
if self.as_type is not None:
return self.as_type(value)
return value
@abc.abstractmethod
def get_value(self, secrets: Secrets) -> typing.Any:
"""
Get and return the field's value. Subclasses must implement this
method.
"""
...
class LiteralField(Field):
"""
Concrete :py:class:`keep_it_secret.Field` subclass that wraps a literal
value.
Example:
.. code-block:: pycon
>>> spam = LiteralField('spam')
>>> spam(secrets)
'spam'
>>> one = LiteralField(1)
>>> one()
>>> one(secrets)
1
>>> anything_works = LiteralField(RuntimeError('BOOM'))
>>> anything_works(secrets)
RuntimeError('BOOM')
:param value: The value to wrap.
"""
def __init__(self, value: typing.Any, **field_options):
field_options['as_type'] = None
super().__init__(**field_options)
self.value = value
@classmethod
def new(cls: type[LiteralField], value: typing.Any, **field_options) -> typing.Any: # type: ignore[override]
return cls(value, **field_options)
def get_value(self, secrets: Secrets) -> typing.Any:
"""Returns the wrapped value."""
return self.value
class EnvField(Field):
"""
Concrete :py:class:`keep_it_secret.Field` subclass that uses ``os.environ``
to resolve the value.
Example:
.. code-block:: pycon
>>> db_password = EnvField('APP_DB_PASSWORD')
>>> db_password(secrets)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/bilbo/Projects/PLAYG/keep_it_secret/keep_it_secret/fields.py", line 83, in __call__
if value is None:
File "/Users/bilbo/Projects/PLAYG/keep_it_secret/keep_it_secret/fields.py", line 129, in get_value
def new(cls: type[EnvField], key: str, default: typing.Any = None, **field_options) -> typing.Any:
keep_it_secret.fields.Field.RequiredValueMissing: APP_DB_PASSWORD
>>> os.environ['APP_DB_PASSWORD'] = 'spam'
>>> db_password(secrets)
'spam'
:param key: Environment dictionary key.
:param default: Default value. Defaults to ``None``.
"""
def __init__(self, key: str, default: typing.Any = None, **field_options):
super().__init__(**field_options)
self.key = key
self.default = default
@classmethod
def new(cls: type[EnvField], # type: ignore[override]
key: str,
default: typing.Any = None,
**field_options) -> typing.Any:
return cls(key, default=default, **field_options)
def get_value(self, secrets: Secrets) -> typing.Any:
"""
Resolve the value using ``os.environ``.
:raises RequiredValueMissing: Signal the field's value is required but
*key* is not present in the environment.
"""
if self.required is True and self.key not in os.environ:
raise self.RequiredValueMissing(self.key)
return os.environ.get(self.key, self.default)
class SecretsField(Field):
"""
Concrete :py:class:`keep_it_secret.Field` subclass that wraps a
:py:class:`keep_it_secret.Secrets` subclass. Provides API to declare
complex secret structures.
Example:
.. code-block:: python
class WeatherAPICredentials(Secrets):
username: str = LiteralField('spam')
password: str = EnvField.new('APP_WEATHER_API_PASSWORD', required=True)
class AppSecrets(Secrets):
db_password: str = EnvField.new('APP_DB_PASSWORD', required=True)
weather_api: WeatherAPICredentials = SecretsField(WeatherAPICredentials)
.. code-block:: pycon
>>> secrets = AppSecrets()
>>> secrets.weather_api.password
'eggs'
>>> secrets.weather_api.__secrets_parent__ == secrets
True
:param klass: :py:class:`keep_it_secret.Secrets` subclass to wrap.
"""
def __init__(self, klass: type[Secrets], **field_options):
field_options['as_type'] = None
super().__init__(**field_options)
self.klass = klass
@classmethod
def new(cls: type[SecretsField], klass: type[Secrets], **field_options) -> typing.Any: # type: ignore[override]
return cls(klass, **field_options)
def get_value(self, secrets: Secrets) -> typing.Any:
"""
Instantiate the wrapped *klass* and return it. *secrets* will be
passed as ``parent`` to the instance.
"""
return self.klass(parent=secrets)
class AbstractField(Field):
"""
The "placeholder" field. Use it in a :py:class:`keep_it_secret.Secrets`
subclass to indicate that the field must be overloaded by a subclass.
Instances will raise :py:exc:`NotImplementedError` during evaluation.
Example:
.. code-block:: python
class BaseSecrets(Secrets):
secret_key: str = AbstractField.new()
class DevelopmentSecrets(BaseSecrets):
secret_key: str = LiteralField.new('thisisntsecure')
class ProductionSecrets(BaseSecrets):
secret_key: str = EnvField.new('APP_SECRET_KEY', required=True)
Trying to instantiate ``BaseSecrets`` will fail:
.. code-block:: pycon
>>> secrets = BaseSecrets()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/bilbo/Projects/PLAYG/keep_it_secret/keep_it_secret/secrets.py", line 105, in __init__
self.__secrets_data__[field_name] = getattr(self, field_name)
File "/Users/bilbo/Projects/PLAYG/keep_it_secret/keep_it_secret/secrets.py", line 27, in getter
instance.__secrets_data__[field_name] = field(instance)
File "/Users/bilbo/Projects/PLAYG/keep_it_secret/keep_it_secret/fields.py", line 83, in __call__
value = self.get_value(secrets)
File "/Users/bilbo/Projects/PLAYG/keep_it_secret/keep_it_secret/fields.py", line 171, in get_value
raise NotImplementedError('Abstract field must be overloaded: `%s`' % self.name)
NotImplementedError: Abstract field must be overloaded: `BaseSecrets.secret_key`
Instantiating ``DevelopmentSecrets`` will work as expected:
.. code-block:: pycon
>>> secrets = DevelopmentSecrets()
>>> secrets.secret_key
'thisisntsecure'
"""
def get_value(self, secrets: Secrets) -> typing.Any:
"""
:raises NotImplementedError: Signal that the field needs to be
overloaded.
"""
raise NotImplementedError('Abstract field must be overloaded: `%s`' % self.name)

0
keep_it_secret/py.typed Normal file
View File

106
keep_it_secret/secrets.py Normal file
View File

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from keep_it_secret.fields import Field
TFields: typing.TypeAlias = dict[str, Field]
def process_class_attributes(attributes) -> tuple[dict[str, typing.Any], TFields]:
new_attributes: dict[str, typing.Any] = {}
fields: TFields = {}
for attribute_name, attribute in attributes.items():
if isinstance(attribute, Field) is True:
fields[attribute_name] = attribute
else:
new_attributes[attribute_name] = attribute
return new_attributes, fields
def field_property_factory(field_name: str, field: Field) -> property:
def getter(instance: Secrets) -> typing.Any:
if field_name not in instance.__secrets_data__:
field: Field = instance.__secrets_fields__[field_name]
instance.__secrets_data__[field_name] = field(instance)
return instance.__secrets_data__[field_name]
return property(fget=getter, doc=field.description)
class SecretsBase(type):
def __new__(cls, name, bases, attributes, **kwargs):
super_new = super().__new__
fields = {}
for base in bases:
if isinstance(base, SecretsBase) is True:
fields.update(base.__secrets_fields__)
new_attributes, cls_fields = process_class_attributes(attributes)
final_fields = {**fields, **cls_fields}
new_attributes['__secrets_fields__'] = final_fields
new_class = super_new(cls, name, bases, new_attributes, **kwargs)
for field_name, field in final_fields.items():
field.name = f'{name}.{field_name}'
setattr(
new_class,
field_name,
field_property_factory(field_name, field),
)
return new_class
class Secrets(metaclass=SecretsBase):
"""
The base Secrets class, used to declare application-specfic secrets
containers.
Example:
.. code-block:: python
class AppSecrets(Secrets):
secret_key: str = AbstractField.new()
db_password: str = EnvField.new('APP_DB_PASSWORD', required=True)
not_a_secret = 'spam'
def do_something(self) -> bool:
return 'eggs'
When instantiated, ``AppSecrets`` will evaluate each of the fields and fill
in instance properties with the appropriate values. Attributes which don't
evaluate to :py:class:`Field` instances will not be modified.
Secrets classes retain their behaviour when they're subclassed. This allows
the developer to compose env-specific secrets:
.. code-block:: python
class DevelopmentSecrets(AppSecrets):
secret_key: str = LiteralField.new('thisisntsecure')
In this case, the ``secret_key`` field gets overloaded, while all the
others remain as declared in ``AppSecrets``.
:param parent: The parent :py:class:`Secrets` subclass or ``None``.
"""
__secrets_fields__: TFields
def __init__(self, parent: Secrets | None = None):
self.__secrets_parent__ = parent
self.__secrets_data__: dict[str, typing.Any] = {}
for field_name, field in self.__secrets_fields__.items():
self.__secrets_data__[field_name] = getattr(self, field_name)

1666
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
pyproject.toml Normal file
View File

@ -0,0 +1,34 @@
[tool.poetry]
name = "keep-it-secret"
version = "1.0.0"
description = "Keep It Secret by BTHLabs"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
boto3 = {version = ">=1.34.0", optional = true}
[tool.poetry.group.dev.dependencies]
boto3 = "1.34.8"
flake8 = "6.1.0"
flake8-commas = "2.1.0"
invoke = "1.7.3"
ipdb = "0.13.13"
ipython = "8.19.0"
moto = "4.2.12"
mypy = "1.8.0"
pytest = "7.4.3"
pytest-env = "1.1.3"
pytest-mock = "3.12.0"
sphinx = "7.2.6"
sphinx-rtd-theme = "2.0.0"
twine = "4.0.2"
[tool.poetry.extras]
aws = ["boto3"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

25
setup.cfg Normal file
View File

@ -0,0 +1,25 @@
[flake8]
exclude = docs/
ignore = E131,E123
max-line-length = 119
hang-closing = False
[tool:pytest]
addopts = --disable-warnings
env =
KEEP_IT_SECRET_TESTS_SPAM=spam
AWS_ACCESS_KEY_ID=thisisntright
AWS_SECRET_ACCESS_KEY=thisisntright
AWS_SESSION_TOKEN=thisisntright
AWS_DEFAULT_REGION=eu-central-1
[mypy]
[mypy-botocore.*]
ignore_missing_imports = True
[mypy-boto3.*]
ignore_missing_imports = True
[mypy-moto.*]
ignore_missing_imports = True

3
skel/envrc Normal file
View File

@ -0,0 +1,3 @@
export VIRTUAL_ENV="$(realpath .venv)"
export PATH="$VIRTUAL_ENV/bin:$PATH"
export PYTHONBREAKPOINT="ipdb.set_trace"

40
tasks.py Normal file
View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# type: ignore
from invoke import task
try:
from ops.tasks import * # noqa: F401,F403
except ImportError:
pass
@task
def flake8(ctx, warn=False):
return ctx.run('flake8', warn=warn)
@task
def mypy(ctx, warn=False):
return ctx.run('mypy .', warn=warn)
@task
def tests(ctx, warn=False):
return ctx.run('pytest -v', warn=warn)
@task
def ci(ctx):
result = True
if flake8(ctx, warn=True).exited != 0:
result = False
if mypy(ctx, warn=True).exited != 0:
result = False
if tests(ctx, warn=True).exited != 0:
result = False
if result is False:
raise RuntimeError('Some checks have failed')

0
tests/__init__.py Normal file
View File

12
tests/conftest.py Normal file
View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import pytest
from .fixtures import TestingSecrets
@pytest.fixture
def testing_secrets() -> TestingSecrets:
return TestingSecrets()

0
tests/ext/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import os
from unittest import mock
from keep_it_secret.ext import aws
@mock.patch.dict(
os.environ,
{
'AWS_ACCESS_KEY_ID': 'test_access_key_id',
'AWS_SECRET_ACCESS_KEY': 'test_secret_access_key',
'AWS_SESSION_TOKEN': 'test_aws_session_token',
'AWS_DEFAULT_REGION': 'test_aws_default_region',
},
)
def test_as_boto3_client_kwargs():
# Given
secrets = aws.AWSSecrets()
# When
result = secrets.as_boto3_client_kwargs()
# Then
assert result == {
'aws_access_key_id': 'test_access_key_id',
'aws_secret_access_key': 'test_secret_access_key',
'aws_session_token': 'test_aws_session_token',
'region_name': 'test_aws_default_region',
}
@mock.patch.dict(os.environ, {}, clear=True)
def test_as_boto3_client_kwargs_empty():
# Given
secrets = aws.AWSSecrets()
# When
result = secrets.as_boto3_client_kwargs()
# Then
assert result == {}

View File

@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import datetime
import json
from unittest import mock
import boto3
import moto
import pytest
from pytest_mock import MockerFixture
from keep_it_secret.ext import aws
from keep_it_secret.secrets import Secrets
class TestingAWSSecrets(Secrets):
aws = aws.AWSSecrets()
@pytest.fixture
def aws_secrets_manager_client() -> mock.Mock:
return mock.Mock(spec=['get_secret_value'])
@pytest.fixture
def mock_boto3_client(mocker: MockerFixture) -> mock.Mock:
return mocker.patch.object(aws.boto3, 'client')
@pytest.fixture
def testing_aws_secrets() -> TestingAWSSecrets:
return TestingAWSSecrets()
def test_init():
# When
result = aws.AWSSecretsManagerField('keep_it_secret/tests/spam')
# Then
assert result.secret_id == 'keep_it_secret/tests/spam'
assert result.default is None
assert result.decoder == json.loads
assert result.client is None
def test_init_with_default():
# When
result = aws.AWSSecretsManagerField(
'keep_it_secret/tests/spam', default='eggs',
)
# Then
assert result.default == 'eggs'
def test_init_with_decoder():
# When
result = aws.AWSSecretsManagerField(
'keep_it_secret/tests/spam', decoder=int,
)
# Then
assert result.decoder == int
def test_init_with_field_options():
# When
result = aws.AWSSecretsManagerField(
'keep_it_secret/tests/spam',
as_type=int,
required=False,
description='spameggs',
)
# Then
assert result.as_type is None
assert result.required is False
assert result.description == 'spameggs'
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
aws.AWSSecretsManagerField, '__init__', return_value=None,
)
# When
_ = aws.AWSSecretsManagerField.new(
'keep_it_secret/tests/spam',
default='eggs',
decoder=json.dumps,
as_type=int,
required=False,
description='spameggs',
)
# Then
mock_init.assert_called_once_with(
'keep_it_secret/tests/spam',
default='eggs',
decoder=json.dumps,
as_type=int,
required=False,
description='spameggs',
)
def test_get_client_cache_miss(mock_boto3_client: mock.Mock,
aws_secrets_manager_client: mock.Mock,
testing_aws_secrets: TestingAWSSecrets):
# Given
mock_boto3_client.return_value = aws_secrets_manager_client
field = aws.AWSSecretsManagerField('keep_it_secret/tests/spam')
# When
result = field.get_client(testing_aws_secrets)
# Then
assert result == aws_secrets_manager_client
assert field.client == aws_secrets_manager_client
mock_boto3_client.assert_called_once_with(
'secretsmanager', **testing_aws_secrets.aws.as_boto3_client_kwargs(),
)
def test_get_client_cache_hit(mock_boto3_client: mock.Mock,
aws_secrets_manager_client: mock.Mock,
testing_aws_secrets: TestingAWSSecrets):
# Given
field = aws.AWSSecretsManagerField('keep_it_secret/tests/spam')
field.client = aws_secrets_manager_client
# When
result = field.get_client(testing_aws_secrets)
# Then
assert result == aws_secrets_manager_client
mock_boto3_client.assert_not_called()
def test_get_value_aws_dependency_missing(mocker: MockerFixture,
aws_secrets_manager_client: mock.Mock,
testing_secrets: Secrets):
# Given
field = aws.AWSSecretsManagerField('keep_it_secret/tests/spam')
mock_field_get_client = mocker.patch.object(
field, 'get_client', return_value=aws_secrets_manager_client,
)
with pytest.raises(field.DependencyMissing) as exception_info:
# When
_ = field(testing_secrets)
# Then
assert exception_info.value.args[0] == 'aws'
mock_field_get_client.assert_not_called()
aws_secrets_manager_client.get_secret_value.assert_not_called()
@moto.mock_secretsmanager
def test_get_value_required_value_not_found(testing_aws_secrets: Secrets):
# Given
field = aws.AWSSecretsManagerField('keep_it_secret/tests/spam')
with pytest.raises(field.RequiredValueMissing) as exception_info:
# When
_ = field(testing_aws_secrets)
# Then
assert exception_info.value.args[0] == 'keep_it_secret/tests/spam'
@moto.mock_secretsmanager
def test_get_value_not_found_not_required(testing_aws_secrets: Secrets):
# Given
field = aws.AWSSecretsManagerField(
'keep_it_secret/tests/spam', required=False,
)
result = field(testing_aws_secrets)
# Then
assert result is None
@moto.mock_secretsmanager
def test_get_value(testing_aws_secrets: Secrets):
# Given
aws_secrets_manager_client = boto3.client('secretsmanager')
aws_secrets_manager_client.create_secret(
Name='keep_it_secret/tests/spam',
SecretString='{"spam":true}',
)
field = aws.AWSSecretsManagerField('keep_it_secret/tests/spam')
# When
result = field(testing_aws_secrets)
# Then
assert result == {"spam": True}
@moto.mock_secretsmanager
def test_get_value_with_custom_decoder(testing_aws_secrets: Secrets):
# Given
aws_secrets_manager_client = boto3.client('secretsmanager')
aws_secrets_manager_client.create_secret(
Name='keep_it_secret/tests/spam',
SecretString='1987-10-03T08:00:00',
)
field = aws.AWSSecretsManagerField(
'keep_it_secret/tests/spam', decoder=datetime.datetime.fromisoformat,
)
# When
result = field(testing_aws_secrets)
# Then
assert result == datetime.datetime(1987, 10, 3, 8, 0, 0)

View File

View File

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from keep_it_secret.ext import loader
from tests.fixtures import TestingSecrets
def test_load_secrets():
# When
result = loader.load_secrets('tests.fixtures', 'testing', 'example')
# Then
assert isinstance(result, TestingSecrets)

0
tests/fields/__init__.py Normal file
View File

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import pytest
from pytest_mock import MockerFixture
from keep_it_secret import fields
from keep_it_secret.secrets import Secrets
def test_init():
# When
_ = fields.AbstractField()
# Then
assert True # Nothing to test here ;)
def test_init_with_field_options():
# When
result = fields.AbstractField(
as_type=int, required=False, description='spameggs',
)
# Then
assert result.as_type == int
assert result.required is False
assert result.description == 'spameggs'
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
fields.AbstractField, '__init__', return_value=None,
)
# When
_ = fields.AbstractField.new(
as_type=int,
required=False,
description='spameggs',
)
# Then
mock_init.assert_called_once_with(
as_type=int,
required=False,
description='spameggs',
)
def test_get_value(testing_secrets: Secrets):
# Given
field = fields.AbstractField()
with pytest.raises(NotImplementedError) as exception_info:
# When
_ = field.get_value(testing_secrets)
# Then
assert field.name in exception_info.value.args[0]

View File

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import pytest
from pytest_mock import MockerFixture
from keep_it_secret import fields
from keep_it_secret.secrets import Secrets
def test_init():
# When
result = fields.EnvField('KEEP_IT_SECRET_TESTS_SPAM')
# Then
assert result.key == 'KEEP_IT_SECRET_TESTS_SPAM'
assert result.default is None
def test_init_with_default():
# When
result = fields.EnvField('KEEP_IT_SECRET_TESTS_SPAM', default='eggs')
# Then
assert result.default == 'eggs'
def test_init_with_field_options():
# When
result = fields.EnvField(
'KEEP_IT_SECRET_TESTS_SPAM',
as_type=int,
required=False,
description='spameggs',
)
# Then
assert result.as_type == int
assert result.required is False
assert result.description == 'spameggs'
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
fields.EnvField, '__init__', return_value=None,
)
# When
_ = fields.EnvField.new(
'KEEP_IT_SECRET_TESTS_SPAM',
default='eggs',
as_type=int,
required=False,
description='spameggs',
)
# Then
mock_init.assert_called_once_with(
'KEEP_IT_SECRET_TESTS_SPAM',
default='eggs',
as_type=int,
required=False,
description='spameggs',
)
def test_get_value(testing_secrets: Secrets):
# Given
field = fields.EnvField('KEEP_IT_SECRET_TESTS_SPAM')
# When
result = field.get_value(testing_secrets)
# Then
assert result == 'spam'
def test_get_value_required_value_missing(testing_secrets: Secrets):
# Given
field = fields.EnvField('KEEP_IT_SECRET_TESTS_IDONTEXIST', default=None)
with pytest.raises(field.RequiredValueMissing) as exception_info:
# When
_ = field.get_value(testing_secrets)
# Then
assert exception_info.value.args[0] == 'KEEP_IT_SECRET_TESTS_IDONTEXIST'
def test_get_value_missing_not_required(testing_secrets: Secrets):
# Given
field = fields.EnvField('KEEP_IT_SECRET_TESTS_IDONTEXIST', required=False)
# When
result = field.get_value(testing_secrets)
# Then
assert result == field.default

117
tests/fields/test_Field.py Normal file
View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import pytest
from pytest_mock import MockerFixture
from keep_it_secret import fields
from keep_it_secret.secrets import Secrets
class TestingField(fields.Field):
def get_value(self, secrets):
return 'test'
def test_init():
# When
result = TestingField()
# Then
assert result.as_type == str
assert result.required is True
assert result.description is None
assert isinstance(result.name, str) is True
assert len(result.name) > 0
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
TestingField, '__init__', return_value=None,
)
# When
_ = TestingField.new(
as_type=int,
required=False,
description='spameggs',
)
# Then
mock_init.assert_called_once_with(
as_type=int,
required=False,
description='spameggs',
)
@pytest.mark.parametrize(
'argument,value',
[
('as_type', int),
('required', False),
('description', 'spam'),
],
)
def test_init_with_kwargs(argument, value):
# When
result = TestingField(**{argument: value})
# Then
assert getattr(result, argument) == value
def test_call(mocker: MockerFixture, testing_secrets: Secrets):
# Given
field = TestingField()
# When
result = field(testing_secrets)
# Then
assert result == 'test'
def test_call_get_value_call(mocker: MockerFixture, testing_secrets: Secrets):
# Given
field = TestingField()
mock_get_value = mocker.patch.object(
field, 'get_value', return_value='spam',
)
# When
result = field(testing_secrets)
# Then
assert result == 'spam'
mock_get_value.assert_called_once_with(testing_secrets)
def test_call_get_value_None(mocker: MockerFixture, testing_secrets: Secrets):
# Given
field = TestingField(as_type=int)
_ = mocker.patch.object(field, 'get_value', return_value=None)
# When
result = field(testing_secrets)
# Then
assert result is None
def test_call_with_as_type(mocker: MockerFixture, testing_secrets: Secrets):
# Given
field = TestingField(as_type=int)
_ = mocker.patch.object(field, 'get_value', return_value='5')
# When
result = field(testing_secrets)
# Then
assert result == 5

View File

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from pytest_mock import MockerFixture
from keep_it_secret import fields
from keep_it_secret.secrets import Secrets
def test_init():
# When
result = fields.LiteralField('spam')
# Then
assert result.as_type is None
assert result.value == 'spam'
def test_init_with_field_options():
# When
result = fields.LiteralField(
'spam', as_type=int, required=False, description='eggs',
)
# Then
assert result.as_type is None
assert result.required is False
assert result.description == 'eggs'
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
fields.LiteralField, '__init__', return_value=None,
)
# When
_ = fields.LiteralField.new(
'spam', as_type=int, required=False, description='eggs',
)
# Then
mock_init.assert_called_once_with(
'spam', as_type=int, required=False, description='eggs',
)
def test_get_value(testing_secrets: Secrets):
# Given
field = fields.LiteralField('spam')
# When
result = field.get_value(testing_secrets)
# Then
assert result == 'spam'

View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock
from pytest_mock import MockerFixture
from keep_it_secret import fields
from keep_it_secret.secrets import Secrets
def test_init():
# When
result = fields.SecretsField(Secrets)
# Then
assert result.as_type is None
assert result.klass == Secrets
def test_init_with_field_options():
# When
result = fields.SecretsField(
Secrets, as_type=int, required=False, description='eggs',
)
# Then
assert result.as_type is None
assert result.required is False
assert result.description == 'eggs'
def test_new(mocker: MockerFixture):
# Given
mock_init = mocker.patch.object(
fields.SecretsField, '__init__', return_value=None,
)
# When
_ = fields.SecretsField.new(
Secrets, as_type=int, required=False, description='eggs',
)
# Then
mock_init.assert_called_once_with(
Secrets, as_type=int, required=False, description='eggs',
)
def test_get_value(testing_secrets: Secrets):
# Given
wrapped_secrets = mock.Mock(spec=Secrets)
mock_secrets_class = mock.Mock(return_value=wrapped_secrets)
field = fields.SecretsField(mock_secrets_class)
# When
result = field.get_value(testing_secrets)
# Then
assert result == wrapped_secrets
mock_secrets_class.assert_called_once_with(parent=testing_secrets)

2
tests/fixtures/__init__.py vendored Normal file
View File

@ -0,0 +1,2 @@
# type: ignore
from .models import TestingSecrets # noqa: F401

11
tests/fixtures/models.py vendored Normal file
View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from keep_it_secret.fields import EnvField, LiteralField
from keep_it_secret.secrets import Secrets
class TestingSecrets(Secrets):
spam: str = EnvField.new('KEEP_IT_SECRET_TESTS_SPAM')
eggs: str = LiteralField.new('eggs')

0
tests/fixtures/testing/__init__.py vendored Normal file
View File

7
tests/fixtures/testing/example.py vendored Normal file
View File

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from tests.fixtures.models import TestingSecrets
__secrets__ = TestingSecrets()

View File

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from keep_it_secret import secrets
from tests.fixtures import TestingSecrets
class ParentSecrets(secrets.Secrets):
pass
def test_init():
# When
result = TestingSecrets()
# Then
assert isinstance(result, TestingSecrets)
assert result.__secrets_parent__ is None
assert result.__secrets_data__ == {'spam': 'spam', 'eggs': 'eggs'}
def test_init_with_parent():
# Given
parent_secrets = ParentSecrets()
# When
result = TestingSecrets(parent=parent_secrets)
# Then
assert result.__secrets_parent__ is parent_secrets
def test_field_property(testing_secrets: TestingSecrets):
# When
result = testing_secrets.spam
# Then
assert result == testing_secrets.__secrets_data__['spam']

View File

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import types
from keep_it_secret import secrets
from keep_it_secret.fields import EnvField, LiteralField
class TestingSecrets(secrets.Secrets):
spam: str = LiteralField.new('spam')
something = False
def do_something(self):
return True
class SubclassSecrets(TestingSecrets):
spam: str = EnvField.new('KEEP_IT_SECRET_TESTS_SPAM')
eggs: str = LiteralField.new('eggs')
def test_generic_attribute_initialization():
# Then
assert TestingSecrets.__class__ is secrets.SecretsBase
assert isinstance(TestingSecrets.__init__, types.FunctionType)
def test_secrets_dunder_attribute_initialization():
# Then
assert hasattr(TestingSecrets, '__secrets_fields__')
assert 'spam' in TestingSecrets.__secrets_fields__
assert isinstance(TestingSecrets.__secrets_fields__['spam'], LiteralField) is True
assert TestingSecrets.__secrets_fields__['spam'].name == 'TestingSecrets.spam'
def test_secret_properties_initialization():
# Then
assert hasattr(TestingSecrets, 'spam')
assert isinstance(TestingSecrets.spam, property)
def test_subclass_attribute_initialization():
# Then
assert hasattr(TestingSecrets, 'something')
assert TestingSecrets.something is False
assert hasattr(TestingSecrets, 'do_something')
assert isinstance(TestingSecrets.do_something, types.FunctionType)
def test_inheritance():
# Then
assert isinstance(SubclassSecrets.__secrets_fields__['spam'], EnvField) is True
assert isinstance(SubclassSecrets.__secrets_fields__['eggs'], LiteralField) is True
assert hasattr(SubclassSecrets, 'do_something')
assert isinstance(SubclassSecrets.do_something, types.FunctionType)
def test_multiple_inheritance():
# Given
class TestingMixin:
def do_something_else(self):
return False
class SubclassWithMixin(TestingMixin, SubclassSecrets):
spameggs = LiteralField.new('spameggs')
# Then
assert isinstance(SubclassWithMixin.__secrets_fields__['spam'], EnvField) is True
assert isinstance(SubclassWithMixin.__secrets_fields__['eggs'], LiteralField) is True
assert isinstance(SubclassWithMixin.__secrets_fields__['spameggs'], LiteralField) is True
assert hasattr(SubclassWithMixin, 'do_something')
assert isinstance(SubclassWithMixin.do_something, types.FunctionType)
assert hasattr(SubclassWithMixin, 'do_something_else')
assert isinstance(SubclassWithMixin.do_something_else, types.FunctionType)

View File

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from unittest import mock
import pytest
from pytest_mock import MockFixture
from keep_it_secret import secrets
from keep_it_secret.secrets import Secrets
class TestingSecrets(Secrets):
pass
@pytest.fixture
def mock_field(mocker: MockFixture) -> mock.Mock:
result = mocker.Mock(return_value='spam')
result.description = None
return result
@pytest.fixture
def testing_secrets() -> TestingSecrets:
yield TestingSecrets()
TestingSecrets.__secrets_fields__ = {}
def test_getter_cache_miss(mock_field: mock.Mock, testing_secrets: TestingSecrets):
# Given
testing_secrets.__secrets_fields__['spam'] = mock_field
# When
result = secrets.field_property_factory('spam', mock_field).fget(testing_secrets)
# Then
assert result == 'spam'
mock_field.assert_called_once_with(testing_secrets)
def test_getter_cache_hit(mock_field: mock.Mock, testing_secrets: TestingSecrets):
# Given
testing_secrets.__secrets_fields__['spam'] = mock_field
testing_secrets.__secrets_data__['spam'] = 'eggs'
# When
result = secrets.field_property_factory('spam', mock_field).fget(testing_secrets)
# Then
assert result == 'eggs'
mock_field.assert_not_called()