diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16f1334b..2c17f98f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,10 +57,12 @@ jobs: run: | uv sync --group testing --all-extras + - run: mkdir coverage + - name: test run: make test env: - COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }} + COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python }} CONTEXT: ${{ runner.os }}-py${{ matrix.python }} - name: uninstall deps @@ -69,20 +71,63 @@ jobs: - name: test without deps run: make test env: - COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }}-without-deps + COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python }}-without-deps CONTEXT: ${{ runner.os }}-py${{ matrix.python }}-without-deps - - run: uv run coverage combine - - run: uv run coverage xml - - - uses: codecov/codecov-action@v4 + - name: store coverage files + uses: actions/upload-artifact@v4 with: - file: ./coverage.xml - env_vars: PYTHON,OS + name: coverage-${{ matrix.python }}-${{ runner.os }} + path: coverage + include-hidden-files: true + + coverage: + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + with: + # needed for diff-cover + fetch-depth: 0 + + - name: get coverage files + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: coverage + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - run: uv sync --group testing --all-extras + + - run: uv run coverage combine coverage + + - run: uv run coverage html --show-contexts --title "Pydantic Settings coverage for ${{ github.sha }}" + + - name: Store coverage html + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: htmlcov + include-hidden-files: true + + - run: uv run coverage xml + + - run: uv run diff-cover coverage.xml --html-report index.html + + - name: Store diff coverage html + uses: actions/upload-artifact@v4 + with: + name: diff-coverage-html + path: index.html + + - run: uv run coverage report --fail-under 98 check: # This job does nothing and is only used for the branch protection if: always() - needs: [lint, test] + needs: [lint, test, coverage] runs-on: ubuntu-latest outputs: diff --git a/pydantic_settings/sources/providers/aws.py b/pydantic_settings/sources/providers/aws.py index 036d16de..22a1a91b 100644 --- a/pydantic_settings/sources/providers/aws.py +++ b/pydantic_settings/sources/providers/aws.py @@ -21,7 +21,7 @@ def import_aws_secrets_manager() -> None: try: from boto3 import client as boto3_client from mypy_boto3_secretsmanager.client import SecretsManagerClient - except ImportError as e: + except ImportError as e: # pragma: no cover raise ImportError( 'AWS Secrets Manager dependencies are not installed, run `pip install pydantic-settings[aws-secrets-manager]`' ) from e diff --git a/pydantic_settings/sources/providers/azure.py b/pydantic_settings/sources/providers/azure.py index 725a875d..04f0bee5 100644 --- a/pydantic_settings/sources/providers/azure.py +++ b/pydantic_settings/sources/providers/azure.py @@ -30,7 +30,7 @@ def import_azure_key_vault() -> None: from azure.core.credentials import TokenCredential from azure.core.exceptions import ResourceNotFoundError from azure.keyvault.secrets import SecretClient - except ImportError as e: + except ImportError as e: # pragma: no cover raise ImportError( 'Azure Key Vault dependencies are not installed, run `pip install pydantic-settings[azure-key-vault]`' ) from e diff --git a/pydantic_settings/sources/providers/gcp.py b/pydantic_settings/sources/providers/gcp.py index 0c3e35cf..ba202222 100644 --- a/pydantic_settings/sources/providers/gcp.py +++ b/pydantic_settings/sources/providers/gcp.py @@ -27,7 +27,7 @@ def import_gcp_secret_manager() -> None: from google.auth import default as google_auth_default from google.auth.credentials import Credentials from google.cloud.secretmanager import SecretManagerServiceClient - except ImportError as e: + except ImportError as e: # pragma: no cover raise ImportError( 'GCP Secret Manager dependencies are not installed, run `pip install pydantic-settings[gcp-secret-manager]`' ) from e diff --git a/pydantic_settings/sources/providers/toml.py b/pydantic_settings/sources/providers/toml.py index 796a9a90..eaff41da 100644 --- a/pydantic_settings/sources/providers/toml.py +++ b/pydantic_settings/sources/providers/toml.py @@ -33,7 +33,7 @@ def import_toml() -> None: return try: import tomli - except ImportError as e: + except ImportError as e: # pragma: no cover raise ImportError('tomli is not installed, run `pip install pydantic-settings[toml]`') from e else: if tomllib is not None: diff --git a/pydantic_settings/sources/utils.py b/pydantic_settings/sources/utils.py index 41c856fc..270a8c17 100644 --- a/pydantic_settings/sources/utils.py +++ b/pydantic_settings/sources/utils.py @@ -46,7 +46,7 @@ def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> if annotation is not None and _lenient_issubclass(annotation, RootModel) and annotation is not RootModel: annotation = cast('type[RootModel[Any]]', annotation) root_annotation = annotation.model_fields['root'].annotation - if root_annotation is not None: + if root_annotation is not None: # pragma: no branch annotation = root_annotation if any(isinstance(md, Json) for md in metadata): # type: ignore[misc] diff --git a/pydantic_settings/utils.py b/pydantic_settings/utils.py index d4326b59..74c99be6 100644 --- a/pydantic_settings/utils.py +++ b/pydantic_settings/utils.py @@ -26,7 +26,7 @@ def path_type_label(p: Path) -> str: if method(p): return name - return 'unknown' + return 'unknown' # pragma: no cover # TODO remove and replace usage by `isinstance(cls, type) and issubclass(cls, class_or_tuple)` diff --git a/pyproject.toml b/pyproject.toml index 54c7a782..5762c336 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ testing = [ "pytest-mock", "pytest-pretty", "moto[secretsmanager]", + "diff-cover>=9.2.0", ] [tool.pytest.ini_options] @@ -89,19 +90,35 @@ filterwarnings = [ 'ignore::DeprecationWarning:botocore.*:', ] +# https://coverage.readthedocs.io/en/latest/config.html#run [tool.coverage.run] -source = ['pydantic_settings'] +include = [ + "pydantic_settings/**/*.py", + "tests/**/*.py", +] branch = true -context = '${CONTEXT}' +# https://coverage.readthedocs.io/en/latest/config.html#report [tool.coverage.report] +skip_covered = true +show_missing = true +ignore_errors = true precision = 2 exclude_lines = [ 'pragma: no cover', 'raise NotImplementedError', - 'raise NotImplemented', 'if TYPE_CHECKING:', + 'if typing.TYPE_CHECKING:', '@overload', + '@deprecated', + '@typing.overload', + '@abstractmethod', + '\(Protocol\):$', + 'typing.assert_never', + '$\s*assert_never\(', + 'if __name__ == .__main__.:', + 'except ImportError as _import_error:', + '$\s*pass$', ] [tool.coverage.paths] diff --git a/tests/test_source_aws_secrets_manager.py b/tests/test_source_aws_secrets_manager.py index eeb43e68..46160070 100644 --- a/tests/test_source_aws_secrets_manager.py +++ b/tests/test_source_aws_secrets_manager.py @@ -43,6 +43,14 @@ class TestAWSSecretsManagerSettingsSource: """Test AWSSecretsManagerSettingsSource.""" + @mock_aws + def test_repr(self) -> None: + client = boto3.client('secretsmanager') + client.create_secret(Name='test-secret', SecretString='{}') + + source = AWSSecretsManagerSettingsSource(BaseSettings, 'test-secret') + assert repr(source) == "AWSSecretsManagerSettingsSource(secret_id='test-secret', env_nested_delimiter='--')" + @mock_aws def test___init__(self) -> None: """Test __init__.""" diff --git a/uv.lock b/uv.lock index 6e8c325f..0b245838 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,8 @@ resolution-markers = [ "python_full_version >= '3.13'", "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", - "python_full_version < '3.10'", + "python_full_version >= '3.9.17' and python_full_version < '3.10'", + "python_full_version < '3.9.17'", ] [[package]] @@ -255,6 +256,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -472,6 +482,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, ] +[[package]] +name = "diff-cover" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9.17'", +] +dependencies = [ + { name = "chardet", marker = "python_full_version < '3.9.17'" }, + { name = "jinja2", marker = "python_full_version < '3.9.17'" }, + { name = "pluggy", marker = "python_full_version < '3.9.17'" }, + { name = "pygments", marker = "python_full_version < '3.9.17'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/3a/e49ccba052a4dda264fbad4f467739ecc63498f7223bfc03d4bfac23ea95/diff_cover-9.2.0.tar.gz", hash = "sha256:85a0b353ebbb678f9e87ea303f75b545bd0baca38f563219bb72f2ae862bba36", size = 94857, upload-time = "2024-09-08T00:38:06.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/8a/bddb8e4aea550066559144e72d3566e9ae2f757b8ac154e769c563f48177/diff_cover-9.2.0-py3-none-any.whl", hash = "sha256:1e24edc51c39e810c47dd9986e76c333ed95859655c091f572e590c39cabbdbe", size = 52561, upload-time = "2024-09-08T00:38:04.96Z" }, +] + +[[package]] +name = "diff-cover" +version = "9.2.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.10.*'", + "python_full_version >= '3.9.17' and python_full_version < '3.10'", +] +dependencies = [ + { name = "chardet", marker = "python_full_version >= '3.9.17'" }, + { name = "jinja2", marker = "python_full_version >= '3.9.17'" }, + { name = "pluggy", marker = "python_full_version >= '3.9.17'" }, + { name = "pygments", marker = "python_full_version >= '3.9.17'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/a8/20e11859ee291893ef9711ca868d277b66cdf886278e73abdb72b172cc5f/diff_cover-9.2.4.tar.gz", hash = "sha256:6ea44711f09199a1b8bcaa2eae002e1f337dd22f2d798fcfd62a6a1554bb2a86", size = 91359, upload-time = "2025-03-09T00:40:31.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/86/45de73eea30a789350ec91a83cdd525a68ac1ed294ae1a24e7de984e971d/diff_cover-9.2.4-py3-none-any.whl", hash = "sha256:c68b34e368b13888cb04a14aeb509821aab594c171a621e8bd3248435c9dd0c9", size = 53185, upload-time = "2025-03-09T00:40:29.543Z" }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -1194,6 +1243,8 @@ linting = [ ] testing = [ { name = "coverage", extra = ["toml"] }, + { name = "diff-cover", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9.17'" }, + { name = "diff-cover", version = "9.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.17'" }, { name = "moto" }, { name = "pytest" }, { name = "pytest-examples" }, @@ -1228,6 +1279,7 @@ linting = [ ] testing = [ { name = "coverage", extras = ["toml"] }, + { name = "diff-cover", specifier = ">=9.2.0" }, { name = "moto", extras = ["secretsmanager"] }, { name = "pytest" }, { name = "pytest-examples" }, @@ -1583,7 +1635,8 @@ name = "urllib3" version = "1.26.20" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.10'", + "python_full_version >= '3.9.17' and python_full_version < '3.10'", + "python_full_version < '3.9.17'", ] sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } wheels = [