Repository: makukha/pydantic-file-secrets (language: python)

Issue:
Add py.typed marker
Without it, mypy will complain about the package being untyped despite sufficient typing information being available.

```
error: Skipping analyzing "pydantic_file_secrets": module is installed, but missing library stubs or py.typed marker  [import-untyped]
note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
```


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index c2cd647..1aabc09 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ This package unties secrets from environment variables config options and implem
 ## Features
 
 * Use secret file source in nested settings models
-* Drop-in replacement of standard `SecretsSettingsSource`
+* Drop-in replacement of standard `SecretsSettingsSource` (up to monkey patching)
 * Plain or nested directory layout: `/run/secrets/dir__key` or `/run/secrets/dir/key`
 * Respects `env_prefix`, `env_nested_delimiter` and other [config options](https://github.com/makukha/pydantic-file-secrets?tab=readme-ov-file#configuration-options)
 * Has `secrets_prefix`, `secrets_nested_delimiter`, [etc.](https://github.com/makukha/pydantic-file-secrets?tab=readme-ov-file#configuration-options) to configure secrets and env vars separately
diff --git a/Taskfile.yml b/Taskfile.yml
index 5231fda..5fcadbb 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -27,25 +27,31 @@ tasks:
   install:
     desc: Install dev python environment.
     cmds:
-      - task: dep:lock
+      - task: dependencies:lock
       - pdm install --check --dev
 
   # dependencies lock in PDM is slow, run only when pyproject.toml changes
-  dep:lock:
+  dependencies:lock:
     internal: true
     sources:
       - pyproject.toml
     generates:
       - pdm.lock
     cmds:
-      - pdm lock
+      - pdm lock --update-reuse
 
   lint:
-    desc: Run linters and code formatters.
+    desc: Run linters.
     cmds:
+      - pdm run mypy src
       - ruff check
       - ruff format --check
 
+  format:
+    desc: Run code formatters.
+    cmds:
+      - ruff format
+
   test:
     desc: Run tests.
     deps: [install]
diff --git a/docs/badge/coverage.svg b/docs/badge/coverage.svg
index 55b37da..d032963 100644
--- a/docs/badge/coverage.svg
+++ b/docs/badge/coverage.svg
@@ -1,21 +1,21 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="coverage: 98.06%">
-	<title>coverage: 98.06%</title>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="122" height="20" role="img" aria-label="coverage: 100.00%">
+	<title>coverage: 100.00%</title>
 	<linearGradient id="s" x2="0" y2="100%">
 		<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
 		<stop offset="1" stop-opacity=".1"/>
 	</linearGradient>
 	<clipPath id="r">
-		<rect width="116" height="20" rx="3" fill="#fff"/>
+		<rect width="122" height="20" rx="3" fill="#fff"/>
 	</clipPath>
 	<g clip-path="url(#r)">
 		<rect width="61" height="20" fill="#555"/>
-		<rect x="61" width="55" height="20" fill="#4c1"/>
-		<rect width="116" height="20" fill="url(#s)"/>
+		<rect x="61" width="61" height="20" fill="#4c1"/>
+		<rect width="122" height="20" fill="url(#s)"/>
 	</g>
 	<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
 		<text aria-hidden="true" x="315.0" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text>
 		<text x="315.0" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text>
-		<text aria-hidden="true" x="875.0" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="450">98.06%</text>
-		<text x="875.0" y="140" transform="scale(.1)" fill="#fff" textLength="450">98.06%</text>
+		<text aria-hidden="true" x="905.0" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">100.00%</text>
+		<text x="905.0" y="140" transform="scale(.1)" fill="#fff" textLength="510">100.00%</text>
 	</g>
 </svg>
diff --git a/pdm.lock b/pdm.lock
index 3cadb56..55d3aec 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -2,10 +2,10 @@
 # It is not intended for manual editing.
 
 [metadata]
-groups = ["default", "build", "test"]
+groups = ["default", "build", "lint", "test"]
 strategy = ["inherit_metadata"]
 lock_version = "4.5.0"
-content_hash = "sha256:3351882c58deebf1b5cfa0f1553810ddfc6690329b9ef697ded63e6c68328be9"
+content_hash = "sha256:979b11aaaa6282846a6492ff46776c4da8461553c12977ff15afb46adfcc95b5"
 
 [[metadata.targets]]
 requires_python = ">=3.8"
@@ -452,6 +452,58 @@ files = [
     {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
 ]
 
+[[package]]
+name = "mypy"
+version = "1.11.2"
+requires_python = ">=3.8"
+summary = "Optional static typing for Python"
+groups = ["lint"]
+dependencies = [
+    "mypy-extensions>=1.0.0",
+    "tomli>=1.1.0; python_version < \"3.11\"",
+    "typing-extensions>=4.6.0",
+]
+files = [
+    {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"},
+    {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"},
+    {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"},
+    {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"},
+    {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"},
+    {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"},
+    {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"},
+    {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"},
+    {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"},
+    {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"},
+    {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"},
+    {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"},
+    {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"},
+    {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"},
+    {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"},
+    {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"},
+    {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"},
+    {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"},
+    {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"},
+    {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"},
+    {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"},
+    {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"},
+    {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"},
+    {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"},
+    {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"},
+    {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"},
+    {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"},
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+requires_python = ">=3.5"
+summary = "Type system extensions for programs checked with the mypy type checker."
+groups = ["lint"]
+files = [
+    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
 [[package]]
 name = "packaging"
 version = "24.1"
@@ -853,6 +905,18 @@ files = [
     {file = "setuptools-72.2.0.tar.gz", hash = "sha256:80aacbf633704e9c8bfa1d99fa5dd4dc59573efcf9e4042c13d3bcef91ac2ef9"},
 ]
 
+[[package]]
+name = "tomli"
+version = "2.0.1"
+requires_python = ">=3.7"
+summary = "A lil' TOML parser"
+groups = ["lint"]
+marker = "python_version < \"3.11\""
+files = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
 [[package]]
 name = "tomlkit"
 version = "0.13.2"
@@ -870,7 +934,7 @@ name = "typing-extensions"
 version = "4.12.2"
 requires_python = ">=3.8"
 summary = "Backported and Experimental Type Hints for Python 3.8+"
-groups = ["default", "build"]
+groups = ["default", "build", "lint"]
 files = [
     {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
     {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
diff --git a/pyproject.toml b/pyproject.toml
index a546717..281db1d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,6 +65,9 @@ build = [
     "bump-my-version; python_version>='3.12'",
     "genbadge[coverage,tests]; python_version>='3.12'",
 ]
+lint = [
+    "mypy>=1.11.2",
+]
 test = [
     "pytest; python_version>='3.12'",
     "pytest-cov; python_version>='3.12'",
diff --git a/src/pydantic_file_secrets/__init__.py b/src/pydantic_file_secrets/__init__.py
index 5170e85..97d7151 100644
--- a/src/pydantic_file_secrets/__init__.py
+++ b/src/pydantic_file_secrets/__init__.py
@@ -4,7 +4,12 @@
 from typing import Any, Literal
 import warnings
 
-from pydantic_settings import BaseSettings, EnvSettingsSource, SecretsSettingsSource, SettingsError
+from pydantic_settings import (
+    BaseSettings,
+    EnvSettingsSource,
+    SecretsSettingsSource,
+    SettingsError,
+)
 from pydantic_settings.sources import parse_env_vars
 from pydantic_settings.utils import path_type_label
 
@@ -14,30 +19,31 @@
 __all__ = ['__version__', 'FileSecretsSettingsSource']
 
 
-type SecretsDirMissing = Literal['ok', 'warn', 'error']
-
-
 class FileSecretsSettingsSource(EnvSettingsSource):
     def __init__(
         self,
-        file_secret_settings: SecretsSettingsSource | BaseSettings,
+        file_secret_settings: SecretsSettingsSource | type[BaseSettings],
         secrets_dir: str | Path | list[str | Path] | None = None,
-        secrets_dir_missing: SecretsDirMissing | None = None,
+        secrets_dir_missing: Literal['ok', 'warn', 'error'] | None = None,
         secrets_dir_max_size: int | None = None,
         secrets_case_sensitive: bool | None = None,
         secrets_prefix: str | None = None,
         secrets_nested_delimiter: str | None = None,
         secrets_nested_subdir: bool | None = None,
+        # args for compatibility with SecretsSettingsSource, don't use directly
+        case_sensitive: bool | None = None,
+        env_prefix: str | None = None,
     ) -> None:
-        if isinstance(file_secret_settings, BaseSettings):
-            # We allow the first argument to be settings_cls like original
-            # SecretsSettingsSource. However, it is recommended to pass
-            # SecretsSettingsSource instance instead (as it is shown in usage examples),
-            # otherwise `_secrets_dir` arg passed to Settings() constructor
-            # will be ignored.
-            settings_cls = file_secret_settings
-        else:
-            settings_cls = file_secret_settings.settings_cls
+        # We allow the first argument to be settings_cls like original
+        # SecretsSettingsSource. However, it is recommended to pass
+        # SecretsSettingsSource instance instead (as it is shown in usage examples),
+        # otherwise `_secrets_dir` arg passed to Settings() constructor
+        # will be ignored.
+        settings_cls: type[BaseSettings] = getattr(
+            file_secret_settings,
+            'settings_cls',
+            file_secret_settings,  # type: ignore[arg-type]
+        )
         # config options
         conf = settings_cls.model_config
         self.secrets_dir: str | Path | list[str | Path] | None = first_not_none(
@@ -45,7 +51,7 @@ def __init__(
             secrets_dir,
             conf.get('secrets_dir'),
         )
-        self.secrets_dir_missing: SecretsDirMissing | None = first_not_none(
+        self.secrets_dir_missing: Literal['ok', 'warn', 'error'] = first_not_none(
             secrets_dir_missing,
             conf.get('secrets_dir_missing'),
             'warn',
@@ -58,12 +64,14 @@ def __init__(
         self.case_sensitive: bool = first_not_none(
             secrets_case_sensitive,
             conf.get('secrets_case_sensitive'),
+            case_sensitive,
             conf.get('case_sensitive'),
             False,
         )
         self.secrets_prefix: str = first_not_none(
             secrets_prefix,
             conf.get('secrets_prefix'),
+            env_prefix,
             conf.get('env_prefix'),
             '',
         )
diff --git a/src/pydantic_file_secrets/py.typed b/src/pydantic_file_secrets/py.typed
new file mode 100644
index 0000000..e69de29


Test patch:
diff --git a/docs/badge/tests.svg b/docs/badge/tests.svg
index e295e07..660c846 100644
--- a/docs/badge/tests.svg
+++ b/docs/badge/tests.svg
@@ -1,5 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="62" height="20" role="img" aria-label="tests: 32">
-	<title>tests: 32</title>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="62" height="20" role="img" aria-label="tests: 33">
+	<title>tests: 33</title>
 	<linearGradient id="s" x2="0" y2="100%">
 		<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
 		<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
 	<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
 		<text aria-hidden="true" x="195.0" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text>
 		<text x="195.0" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text>
-		<text aria-hidden="true" x="485.0" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="150">32</text>
-		<text x="485.0" y="140" transform="scale(.1)" fill="#fff" textLength="150">32</text>
+		<text aria-hidden="true" x="485.0" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="150">33</text>
+		<text x="485.0" y="140" transform="scale(.1)" fill="#fff" textLength="150">33</text>
 	</g>
 </svg>
diff --git a/tests/test_dropin_compatibility.py b/tests/test_dropin_compatibility.py
new file mode 100644
index 0000000..c083cc9
--- /dev/null
+++ b/tests/test_dropin_compatibility.py
@@ -0,0 +1,29 @@
+import pytest
+
+import pydantic_settings.main  # import to monkeypatch
+from pydantic_file_secrets import FileSecretsSettingsSource
+
+
+@pytest.fixture
+def monkeypatch_settings(monkeypatch):
+    monkeypatch.setattr(
+        pydantic_settings.main,
+        'SecretsSettingsSource',
+        FileSecretsSettingsSource,
+    )
+
+
+def test_dropin(settings_model, monkeypatch_settings, secrets_dir):
+    secrets_dir.add_files(
+        ('dir1/key1', 'secret1'),
+        ('dir2/key2', 'secret2'),
+    )
+
+    class Settings(pydantic_settings.BaseSettings):
+        key1: str
+        key2: str
+
+    conf = Settings(_secrets_dir=[secrets_dir / 'dir1', secrets_dir / 'dir2'])
+
+    assert conf.key1 == 'secret1'
+    assert conf.key2 == 'secret2'