diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bb644..02a4ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## Keep It Secret Changelog +#### v1.1.0 (2024-01-18) + +* `Secrets.resolve_dependency()` API for resolving field dependencies. +* AWS Secrets Manager client management moved from `AWSSecretsManagerField` to + `AWSSecrets`. +* Docs tweaked. + #### v1.0.0 (2024-01-04) * Initial public release. diff --git a/docs/source/conf.py b/docs/source/conf.py index 2131aa8..ed912f2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,10 +11,10 @@ project = 'Keep It Secret' copyright = '2023-present Tomek Wójcik' author = 'Tomek Wójcik' -version = '1.0.0' +version = '1.1.0' # The full version, including alpha/beta/rc tags -release = '1.0.0' +release = '1.1.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -30,5 +30,8 @@ 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_theme = 'furo' html_static_path = ['_static'] + +# -- Options for autodoc ----------------------------------------------------- +autodoc_member_order = 'bysource' diff --git a/keep_it_secret/__init__.py b/keep_it_secret/__init__.py index 026967e..08bc0c5 100644 --- a/keep_it_secret/__init__.py +++ b/keep_it_secret/__init__.py @@ -6,7 +6,7 @@ from .fields import ( # noqa: F401 ) from .secrets import Secrets # noqa: F401 -__version__ = '1.0.0' +__version__ = '1.1.0' __all__ = [ 'AbstractField', diff --git a/keep_it_secret/ext/aws.py b/keep_it_secret/ext/aws.py index 504a337..d70f497 100644 --- a/keep_it_secret/ext/aws.py +++ b/keep_it_secret/ext/aws.py @@ -57,6 +57,11 @@ class AWSSecrets(Secrets): :type: ``str | None`` """ + def __init__(self, parent: Secrets | None = None): + super().__init__(parent=parent) + + self.client: PAWSSecretsManagerClient | None = None + def as_boto3_client_kwargs(self) -> dict[str, typing.Any]: """ Return representation of the mapped variables for use in @@ -78,6 +83,15 @@ class AWSSecrets(Secrets): return result + def get_client(self) -> PAWSSecretsManagerClient: + if self.client is None: + self.client = boto3.client( + 'secretsmanager', + **self.as_boto3_client_kwargs(), + ) + + return self.client + class AWSSecretsManagerField(Field): """ @@ -87,7 +101,7 @@ class AWSSecretsManagerField(Field): :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``. + :py:func:`json.loads`. """ def __init__(self, secret_id: str, @@ -100,8 +114,6 @@ class AWSSecretsManagerField(Field): self.default = default self.decoder = decoder - self.client: PAWSSecretsManagerClient | None = None - @classmethod def new(cls: type[AWSSecretsManagerField], # type: ignore[override] secret_id: str, @@ -110,33 +122,25 @@ class AWSSecretsManagerField(Field): **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. + Depends on :py:class:`AWSSecrets` to be declared in ``aws`` field on + ``secrets`` or one of its parents. :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: + aws_secrets: AWSSecrets = secrets.resolve_dependency( + 'aws', include_parents=True, + ) + if aws_secrets is secrets.UNRESOLVED_DEPENDENCY: raise self.DependencyMissing('aws') - client = self.get_client(secrets) + client = aws_secrets.get_client() try: secret = client.get_secret_value(SecretId=self.secret_id) diff --git a/keep_it_secret/secrets.py b/keep_it_secret/secrets.py index 8bb232d..c006f7c 100644 --- a/keep_it_secret/secrets.py +++ b/keep_it_secret/secrets.py @@ -95,6 +95,10 @@ class Secrets(metaclass=SecretsBase): :param parent: The parent :py:class:`Secrets` subclass or ``None``. """ + + #: Sentinel for unresolved dependency. + UNRESOLVED_DEPENDENCY: list[typing.Any] = [] + __secrets_fields__: TFields def __init__(self, parent: Secrets | None = None): @@ -104,3 +108,24 @@ class Secrets(metaclass=SecretsBase): for field_name, field in self.__secrets_fields__.items(): self.__secrets_data__[field_name] = getattr(self, field_name) + + def resolve_dependency(self, + name: str, + *, + include_parents: bool = True) -> typing.Any: + """ + Resolve a dependency field identified by *name* and return its value. + returns :py:attr:`keep_it_secret.Secrets.UNRESOLVED_DEPENDENCY` if + the value can't be resolved. + + :param include_parents: Recursively include parents, if any. + """ + if name in self.__secrets_fields__: + return getattr(self, name) + + if include_parents is True and self.__secrets_parent__ is not None: + return self.__secrets_parent__.resolve_dependency( + name, include_parents=include_parents, + ) + + return self.UNRESOLVED_DEPENDENCY diff --git a/poetry.lock b/poetry.lock index dca1f6a..7f2203f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -43,6 +43,27 @@ files = [ [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "boto3" version = "1.34.8" @@ -391,6 +412,23 @@ files = [ [package.dependencies] flake8 = ">=2" +[[package]] +name = "furo" +version = "2023.9.10" +description = "A clean customisable Sphinx documentation theme." +optional = false +python-versions = ">=3.8" +files = [ + {file = "furo-2023.9.10-py3-none-any.whl", hash = "sha256:513092538537dc5c596691da06e3c370714ec99bc438680edc1debffb73e5bfc"}, + {file = "furo-2023.9.10.tar.gz", hash = "sha256:5707530a476d2a63b8cad83b4f961f3739a69f4b058bcf38a03a39fa537195b2"}, +] + +[package.dependencies] +beautifulsoup4 = "*" +pygments = ">=2.7" +sphinx = ">=6.0,<8.0" +sphinx-basic-ng = "*" + [[package]] name = "idna" version = "3.6" @@ -1337,6 +1375,17 @@ files = [ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + [[package]] name = "sphinx" version = "7.2.6" @@ -1372,23 +1421,21 @@ lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"] [[package]] -name = "sphinx-rtd-theme" -version = "2.0.0" -description = "Read the Docs theme for Sphinx" +name = "sphinx-basic-ng" +version = "1.0.0b2" +description = "A modern skeleton for Sphinx themes." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, - {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, + {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"}, + {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"}, ] [package.dependencies] -docutils = "<0.21" -sphinx = ">=5,<8" -sphinxcontrib-jquery = ">=4,<5" +sphinx = ">=4.0" [package.extras] -dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] +docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] [[package]] name = "sphinxcontrib-applehelp" @@ -1444,20 +1491,6 @@ Sphinx = ">=5" lint = ["docutils-stubs", "flake8", "mypy"] test = ["html5lib", "pytest"] -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -description = "Extension to include jQuery on newer Sphinx releases" -optional = false -python-versions = ">=2.7" -files = [ - {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, - {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, -] - -[package.dependencies] -Sphinx = ">=1.8" - [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" @@ -1663,4 +1696,4 @@ aws = ["boto3"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "10bea99532b925eac76461f71e685103a771c5549deeaff65f9a96df9e585341" +content-hash = "e0cabda70067d490af2f9c9e9251384fb43454c545da6a071e25b1b74cc9f951" diff --git a/pyproject.toml b/pyproject.toml index 0d30a62..237dd3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,14 @@ [tool.poetry] name = "keep-it-secret" -version = "1.0.0" +version = "1.1.0" description = "Keep It Secret by BTHLabs" authors = ["Tomek Wójcik "] +maintainers = ["BTHLabs "] license = "MIT" readme = "README.md" +homepage = "https://projects.bthlabs.pl/keep-it-secret/" +repository = "https://git.bthlabs.pl/tomekwojcik/keep-it-secret/" +documentation = "https://projects.bthlabs.pl/keep-it-secret/" [tool.poetry.dependencies] python = "^3.10" @@ -14,6 +18,7 @@ boto3 = {version = ">=1.34.0", optional = true} boto3 = "1.34.8" flake8 = "6.1.0" flake8-commas = "2.1.0" +furo = "2023.9.10" invoke = "1.7.3" ipdb = "0.13.13" ipython = "8.19.0" @@ -23,7 +28,6 @@ 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] diff --git a/tests/ext/aws/conftest.py b/tests/ext/aws/conftest.py new file mode 100644 index 0000000..8d4e5c9 --- /dev/null +++ b/tests/ext/aws/conftest.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# type: ignore +from __future__ import annotations + +import pytest + +from .fixtures import TestingAWSSecrets + + +@pytest.fixture +def testing_aws_secrets() -> TestingAWSSecrets: + return TestingAWSSecrets() diff --git a/tests/ext/aws/fixtures.py b/tests/ext/aws/fixtures.py new file mode 100644 index 0000000..d757a55 --- /dev/null +++ b/tests/ext/aws/fixtures.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +from keep_it_secret.ext.aws import AWSSecrets +from keep_it_secret.fields import SecretsField +from keep_it_secret.secrets import Secrets + + +class TestingAWSSecrets(Secrets): + aws = SecretsField.new(AWSSecrets) diff --git a/tests/ext/aws/test_AWSSecrets.py b/tests/ext/aws/test_AWSSecrets.py index bdfce40..e585597 100644 --- a/tests/ext/aws/test_AWSSecrets.py +++ b/tests/ext/aws/test_AWSSecrets.py @@ -5,8 +5,31 @@ from __future__ import annotations import os from unittest import mock +import pytest +from pytest_mock import MockerFixture + from keep_it_secret.ext import aws +from .fixtures import TestingAWSSecrets + + +@pytest.fixture +def mock_boto3_client(mocker: MockerFixture) -> mock.Mock: + return mocker.patch.object(aws.boto3, 'client') + + +@pytest.fixture +def aws_secrets_manager_client() -> mock.Mock: + return mock.Mock() + + +def test_init(): + # When + result = aws.AWSSecrets() + + # Then + result.client is None + @mock.patch.dict( os.environ, @@ -43,3 +66,40 @@ def test_as_boto3_client_kwargs_empty(): # Then assert result == {} + + +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.AWSSecrets() + + # When + result = field.get_client() + + # 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.AWSSecrets() + field.client = aws_secrets_manager_client + + # When + result = field.get_client() + + # Then + assert result == aws_secrets_manager_client + + mock_boto3_client.assert_not_called() diff --git a/tests/ext/aws/test_AWSSecretsManagerField.py b/tests/ext/aws/test_AWSSecretsManagerField.py index 492c96f..6338dfa 100644 --- a/tests/ext/aws/test_AWSSecretsManagerField.py +++ b/tests/ext/aws/test_AWSSecretsManagerField.py @@ -4,7 +4,6 @@ from __future__ import annotations import datetime import json -from unittest import mock import boto3 import moto @@ -15,25 +14,6 @@ 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') @@ -42,7 +22,6 @@ def test_init(): 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(): @@ -107,53 +86,11 @@ def test_new(mocker: MockerFixture): ) -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) @@ -161,10 +98,6 @@ def test_get_value_aws_dependency_missing(mocker: MockerFixture, # 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): diff --git a/tests/secrets/test_Secrets.py b/tests/secrets/test_Secrets.py index d144dea..25ce76c 100644 --- a/tests/secrets/test_Secrets.py +++ b/tests/secrets/test_Secrets.py @@ -1,14 +1,15 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*-x # type: ignore from __future__ import annotations from keep_it_secret import secrets +from keep_it_secret.fields import LiteralField from tests.fixtures import TestingSecrets -class ParentSecrets(secrets.Secrets): - pass +class NestedSecrets(secrets.Secrets): + spameggs: str = LiteralField.new('spameggs') def test_init(): @@ -21,15 +22,12 @@ def test_init(): assert result.__secrets_data__ == {'spam': 'spam', 'eggs': 'eggs'} -def test_init_with_parent(): - # Given - parent_secrets = ParentSecrets() - +def test_init_with_parent(testing_secrets: TestingSecrets): # When - result = TestingSecrets(parent=parent_secrets) + result = NestedSecrets(parent=testing_secrets) # Then - assert result.__secrets_parent__ is parent_secrets + assert result.__secrets_parent__ is testing_secrets def test_field_property(testing_secrets: TestingSecrets): @@ -38,3 +36,58 @@ def test_field_property(testing_secrets: TestingSecrets): # Then assert result == testing_secrets.__secrets_data__['spam'] + + +def test_resolve_dependency_from_self(): + # Given + secrets = NestedSecrets() + + # When + result = secrets.resolve_dependency('spameggs') + + # Then + assert result == secrets.spameggs + + +def test_resolve_dependency_from_parent(testing_secrets: TestingSecrets): + # Given + secrets = NestedSecrets(parent=testing_secrets) + + # When + result = secrets.resolve_dependency('spam') + + # Then + assert result == testing_secrets.spam + + +def test_resolve_dependency_unresolved_from_self(): + # Given + secrets = NestedSecrets() + + # When + result = secrets.resolve_dependency('thisisntright') + + # Then + assert result is secrets.UNRESOLVED_DEPENDENCY + + +def test_resolve_dependency_unresolved_with_parent(testing_secrets: TestingSecrets): + # Given + secrets = NestedSecrets(parent=testing_secrets) + + # When + result = secrets.resolve_dependency('thisisntright') + + # Then + assert result is secrets.UNRESOLVED_DEPENDENCY + + +def test_resolve_dependency_unresolved_dont_include_parents(testing_secrets: TestingSecrets): + # Given + secrets = NestedSecrets(parent=testing_secrets) + + # When + result = secrets.resolve_dependency('spam', include_parents=False) + + # Then + assert result is secrets.UNRESOLVED_DEPENDENCY