Release v1.0.0
This commit is contained in:
commit
19c8d10645
166
.gitignore
vendored
Normal file
166
.gitignore
vendored
Normal 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
5
CHANGELOG.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
## Keep It Secret Changelog
|
||||||
|
|
||||||
|
#### v1.0.0 (2024-01-04)
|
||||||
|
|
||||||
|
* Initial public release.
|
19
LICENSE
Normal file
19
LICENSE
Normal 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
88
README.md
Normal 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
20
docs/Makefile
Normal 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
36
docs/source/api.rst
Normal 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
34
docs/source/conf.py
Normal 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
31
docs/source/ext.rst
Normal 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
16
docs/source/index.rst
Normal 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
80
docs/source/overview.rst
Normal 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
3
invoke.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
run:
|
||||||
|
echo: true
|
||||||
|
pty: true
|
18
keep_it_secret/__init__.py
Normal file
18
keep_it_secret/__init__.py
Normal 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',
|
||||||
|
]
|
0
keep_it_secret/ext/__init__.py
Normal file
0
keep_it_secret/ext/__init__.py
Normal file
149
keep_it_secret/ext/aws.py
Normal file
149
keep_it_secret/ext/aws.py
Normal 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
|
18
keep_it_secret/ext/loader.py
Normal file
18
keep_it_secret/ext/loader.py
Normal 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
287
keep_it_secret/fields.py
Normal 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
0
keep_it_secret/py.typed
Normal file
106
keep_it_secret/secrets.py
Normal file
106
keep_it_secret/secrets.py
Normal 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
1666
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
pyproject.toml
Normal file
34
pyproject.toml
Normal 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
25
setup.cfg
Normal 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
3
skel/envrc
Normal 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
40
tasks.py
Normal 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
0
tests/__init__.py
Normal file
12
tests/conftest.py
Normal file
12
tests/conftest.py
Normal 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
0
tests/ext/__init__.py
Normal file
0
tests/ext/aws/__init__.py
Normal file
0
tests/ext/aws/__init__.py
Normal file
45
tests/ext/aws/test_AWSSecrets.py
Normal file
45
tests/ext/aws/test_AWSSecrets.py
Normal 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 == {}
|
230
tests/ext/aws/test_AWSSecretsManagerField.py
Normal file
230
tests/ext/aws/test_AWSSecretsManagerField.py
Normal 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)
|
0
tests/ext/loader/__init__.py
Normal file
0
tests/ext/loader/__init__.py
Normal file
15
tests/ext/loader/test_load_secrets.py
Normal file
15
tests/ext/loader/test_load_secrets.py
Normal 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
0
tests/fields/__init__.py
Normal file
62
tests/fields/test_AbstractField.py
Normal file
62
tests/fields/test_AbstractField.py
Normal 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]
|
100
tests/fields/test_EnvField.py
Normal file
100
tests/fields/test_EnvField.py
Normal 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
117
tests/fields/test_Field.py
Normal 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
|
57
tests/fields/test_LiteralField.py
Normal file
57
tests/fields/test_LiteralField.py
Normal 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'
|
64
tests/fields/test_SecretsField.py
Normal file
64
tests/fields/test_SecretsField.py
Normal 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
2
tests/fixtures/__init__.py
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# type: ignore
|
||||||
|
from .models import TestingSecrets # noqa: F401
|
11
tests/fixtures/models.py
vendored
Normal file
11
tests/fixtures/models.py
vendored
Normal 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
0
tests/fixtures/testing/__init__.py
vendored
Normal file
7
tests/fixtures/testing/example.py
vendored
Normal file
7
tests/fixtures/testing/example.py
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# type: ignore
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.fixtures.models import TestingSecrets
|
||||||
|
|
||||||
|
__secrets__ = TestingSecrets()
|
0
tests/secrets/__init__.py
Normal file
0
tests/secrets/__init__.py
Normal file
40
tests/secrets/test_Secrets.py
Normal file
40
tests/secrets/test_Secrets.py
Normal 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']
|
82
tests/secrets/test_SecretsBase.py
Normal file
82
tests/secrets/test_SecretsBase.py
Normal 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)
|
57
tests/secrets/test_field_property_factory.py
Normal file
57
tests/secrets/test_field_property_factory.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user