confkit 2.1.1__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {confkit-2.1.1 → confkit-2.2.0}/.pre-commit-config.yaml +5 -5
  2. {confkit-2.1.1 → confkit-2.2.0}/PKG-INFO +4 -4
  3. confkit-2.2.0/docs/examples/delegate.md +67 -0
  4. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/index.md +1 -0
  5. confkit-2.2.0/examples/delegate.py +32 -0
  6. {confkit-2.1.1 → confkit-2.2.0}/mkdocs.yml +1 -0
  7. {confkit-2.1.1 → confkit-2.2.0}/pyproject.toml +17 -17
  8. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/config.py +2 -2
  9. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/data_types.py +1 -1
  10. confkit-2.2.0/src/confkit/ext/delegate.py +143 -0
  11. confkit-2.2.0/tests/test_delegate.py +168 -0
  12. {confkit-2.1.1 → confkit-2.2.0}/uv.lock +2 -7
  13. {confkit-2.1.1 → confkit-2.2.0}/zensical.toml +1 -0
  14. {confkit-2.1.1 → confkit-2.2.0}/.bumpversion.toml +0 -0
  15. {confkit-2.1.1 → confkit-2.2.0}/.github/FUNDING.yml +0 -0
  16. {confkit-2.1.1 → confkit-2.2.0}/.github/copilot-instructions.md +0 -0
  17. {confkit-2.1.1 → confkit-2.2.0}/.github/dependabot.yml +0 -0
  18. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/bump-version-by-labels.yml +0 -0
  19. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/coverage.yml +0 -0
  20. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/docs.yml +0 -0
  21. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/gitleaks.yml +0 -0
  22. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/lint-public.yml +0 -0
  23. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/lint.yml +0 -0
  24. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/pypi.yml +0 -0
  25. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/release.yml +0 -0
  26. {confkit-2.1.1 → confkit-2.2.0}/.github/workflows/test.yml +0 -0
  27. {confkit-2.1.1 → confkit-2.2.0}/.gitignore +0 -0
  28. {confkit-2.1.1 → confkit-2.2.0}/.python-version +0 -0
  29. {confkit-2.1.1 → confkit-2.2.0}/.vscode/settings.json +0 -0
  30. {confkit-2.1.1 → confkit-2.2.0}/LICENSE +0 -0
  31. {confkit-2.1.1 → confkit-2.2.0}/README.md +0 -0
  32. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/argparse.md +0 -0
  33. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/basic.md +0 -0
  34. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/custom_data_type.md +0 -0
  35. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/data_types.md +0 -0
  36. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/decorators.md +0 -0
  37. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/enums.md +0 -0
  38. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/list_types.md +0 -0
  39. {confkit-2.1.1 → confkit-2.2.0}/docs/examples/optional_values.md +0 -0
  40. {confkit-2.1.1 → confkit-2.2.0}/docs/index.md +0 -0
  41. {confkit-2.1.1 → confkit-2.2.0}/docs/markdown.md +0 -0
  42. {confkit-2.1.1 → confkit-2.2.0}/docs/reference/config.md +0 -0
  43. {confkit-2.1.1 → confkit-2.2.0}/docs/reference/data_types.md +0 -0
  44. {confkit-2.1.1 → confkit-2.2.0}/docs/reference/exceptions.md +0 -0
  45. {confkit-2.1.1 → confkit-2.2.0}/docs/reference/notes_two_instances.md +0 -0
  46. {confkit-2.1.1 → confkit-2.2.0}/docs/usage.md +0 -0
  47. {confkit-2.1.1 → confkit-2.2.0}/examples/api.ini +0 -0
  48. {confkit-2.1.1 → confkit-2.2.0}/examples/argparse_example.py +0 -0
  49. {confkit-2.1.1 → confkit-2.2.0}/examples/args.ini +0 -0
  50. {confkit-2.1.1 → confkit-2.2.0}/examples/basic.py +0 -0
  51. {confkit-2.1.1 → confkit-2.2.0}/examples/config.ini +0 -0
  52. {confkit-2.1.1 → confkit-2.2.0}/examples/custom_data_type.py +0 -0
  53. {confkit-2.1.1 → confkit-2.2.0}/examples/data_types.py +0 -0
  54. {confkit-2.1.1 → confkit-2.2.0}/examples/database.ini +0 -0
  55. {confkit-2.1.1 → confkit-2.2.0}/examples/decorators.py +0 -0
  56. {confkit-2.1.1 → confkit-2.2.0}/examples/enums.py +0 -0
  57. {confkit-2.1.1 → confkit-2.2.0}/examples/example.json +0 -0
  58. {confkit-2.1.1 → confkit-2.2.0}/examples/example.toml +0 -0
  59. {confkit-2.1.1 → confkit-2.2.0}/examples/example.yaml +0 -0
  60. {confkit-2.1.1 → confkit-2.2.0}/examples/file_change_event.py +0 -0
  61. {confkit-2.1.1 → confkit-2.2.0}/examples/list_types.py +0 -0
  62. {confkit-2.1.1 → confkit-2.2.0}/examples/multiple_configs.py +0 -0
  63. {confkit-2.1.1 → confkit-2.2.0}/examples/nested_config.py +0 -0
  64. {confkit-2.1.1 → confkit-2.2.0}/examples/nested_example.ini +0 -0
  65. {confkit-2.1.1 → confkit-2.2.0}/examples/nested_example.json +0 -0
  66. {confkit-2.1.1 → confkit-2.2.0}/examples/nested_example.toml +0 -0
  67. {confkit-2.1.1 → confkit-2.2.0}/examples/nested_example.yaml +0 -0
  68. {confkit-2.1.1 → confkit-2.2.0}/examples/optional_values.py +0 -0
  69. {confkit-2.1.1 → confkit-2.2.0}/examples/other_file_types.py +0 -0
  70. {confkit-2.1.1 → confkit-2.2.0}/examples/pydantic_example.py +0 -0
  71. {confkit-2.1.1 → confkit-2.2.0}/examples/references.py +0 -0
  72. {confkit-2.1.1 → confkit-2.2.0}/examples/url_example.py +0 -0
  73. {confkit-2.1.1 → confkit-2.2.0}/ruff.toml +0 -0
  74. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/__init__.py +0 -0
  75. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/exceptions.py +0 -0
  76. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/ext/__init__.py +0 -0
  77. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/ext/parsers.py +0 -0
  78. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/ext/pydantic.py +0 -0
  79. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/ext/reference.py +0 -0
  80. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/parsers.py +0 -0
  81. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/py.typed +0 -0
  82. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/sentinels.py +0 -0
  83. {confkit-2.1.1 → confkit-2.2.0}/src/confkit/watcher.py +0 -0
  84. {confkit-2.1.1 → confkit-2.2.0}/tests/__init__.py +0 -0
  85. {confkit-2.1.1 → confkit-2.2.0}/tests/conftest.py +0 -0
  86. {confkit-2.1.1 → confkit-2.2.0}/tests/test_config.py +0 -0
  87. {confkit-2.1.1 → confkit-2.2.0}/tests/test_config_classvars.py +0 -0
  88. {confkit-2.1.1 → confkit-2.2.0}/tests/test_config_decorators.py +0 -0
  89. {confkit-2.1.1 → confkit-2.2.0}/tests/test_config_detect_parser.py +0 -0
  90. {confkit-2.1.1 → confkit-2.2.0}/tests/test_data_types.py +0 -0
  91. {confkit-2.1.1 → confkit-2.2.0}/tests/test_enum_allowed_values.py +0 -0
  92. {confkit-2.1.1 → confkit-2.2.0}/tests/test_enum_auto_conversion.py +0 -0
  93. {confkit-2.1.1 → confkit-2.2.0}/tests/test_env_parser.py +0 -0
  94. {confkit-2.1.1 → confkit-2.2.0}/tests/test_examples_run.py +0 -0
  95. {confkit-2.1.1 → confkit-2.2.0}/tests/test_list_type.py +0 -0
  96. {confkit-2.1.1 → confkit-2.2.0}/tests/test_metaclass.py +0 -0
  97. {confkit-2.1.1 → confkit-2.2.0}/tests/test_msgspec.py +0 -0
  98. {confkit-2.1.1 → confkit-2.2.0}/tests/test_msgspecparser_branches.py +0 -0
  99. {confkit-2.1.1 → confkit-2.2.0}/tests/test_msgspecparser_no_msgspec.py +0 -0
  100. {confkit-2.1.1 → confkit-2.2.0}/tests/test_msgspecparser_read.py +0 -0
  101. {confkit-2.1.1 → confkit-2.2.0}/tests/test_msgspecparser_sections.py +0 -0
  102. {confkit-2.1.1 → confkit-2.2.0}/tests/test_multiple_configurations.py +0 -0
  103. {confkit-2.1.1 → confkit-2.2.0}/tests/test_nested_config.py +0 -0
  104. {confkit-2.1.1 → confkit-2.2.0}/tests/test_on_file_change.py +0 -0
  105. {confkit-2.1.1 → confkit-2.2.0}/tests/test_pydantic_models.py +0 -0
  106. {confkit-2.1.1 → confkit-2.2.0}/tests/test_sentinel.py +0 -0
  107. {confkit-2.1.1 → confkit-2.2.0}/tests/test_two_instances.py +0 -0
  108. {confkit-2.1.1 → confkit-2.2.0}/tests/test_unreachable_paths.py +0 -0
@@ -6,7 +6,7 @@ default_install_hook_types:
6
6
 
7
7
  repos:
8
8
  - repo: https://github.com/astral-sh/uv-pre-commit
9
- rev: 0.9.26
9
+ rev: 0.13.5
10
10
  hooks:
11
11
  - id: uv-lock
12
12
  stages:
@@ -51,19 +51,19 @@ repos:
51
51
 
52
52
  # Security scanning
53
53
  - repo: https://github.com/gitleaks/gitleaks
54
- rev: v8.24.2
54
+ rev: v8.30.0
55
55
  hooks:
56
56
  - id: gitleaks
57
57
 
58
58
  # Validate packaging metadata early
59
59
  - repo: https://github.com/abravalheri/validate-pyproject
60
- rev: v0.24.1
60
+ rev: v0.27
61
61
  hooks:
62
62
  - id: validate-pyproject
63
63
  stages: [pre-push, pre-commit]
64
64
 
65
65
  - repo: https://github.com/astral-sh/ruff-pre-commit
66
- rev: v0.14.14
66
+ rev: v0.17.2
67
67
  hooks:
68
68
  - id: ruff
69
69
  name: ruff lint
@@ -97,7 +97,7 @@ repos:
97
97
 
98
98
  # Spell checking (global)
99
99
  - repo: https://github.com/shellcheck-py/shellcheck-py
100
- rev: v0.11.0.1
100
+ rev: v0.11.0.1-1
101
101
  hooks:
102
102
  - id: shellcheck
103
103
  stages: [pre-push, pre-commit]
@@ -1,17 +1,17 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: confkit
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: Lightweight and Easy to use configuration manager for Python projects
5
5
  Author: HEROgold
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.11
8
- Requires-Dist: typing-extensions>=4.5.0
8
+ Requires-Dist: typing-extensions>=4.16.0
9
9
  Provides-Extra: msgspec
10
- Requires-Dist: msgspec>=0.20.0; extra == 'msgspec'
10
+ Requires-Dist: msgspec>=0.22.0; extra == 'msgspec'
11
11
  Requires-Dist: pyyaml>=6.0.3; extra == 'msgspec'
12
12
  Requires-Dist: tomli-w>=1.2.0; extra == 'msgspec'
13
13
  Provides-Extra: pydantic
14
- Requires-Dist: pydantic>=2.12.5; extra == 'pydantic'
14
+ Requires-Dist: pydantic>=2.14.2; extra == 'pydantic'
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  # confkit
@@ -0,0 +1,67 @@
1
+ # Delegate Example ([`delegate.py`](https://github.com/HEROgold/confkit/blob/master/examples/delegate.py))
2
+
3
+ ## Purpose
4
+
5
+ Shows how one setting can read and write a setting in another config file, so that file holds the only copy of the value.
6
+
7
+ ## Key Concepts
8
+
9
+ - `Delegate(Settings.dashboard_url)` passes reads and writes through to `Settings.dashboard_url`
10
+ - The delegate stores nothing, so its own file gets no key for it
11
+ - Type checkers see the delegate with the target's type
12
+
13
+ ## Running
14
+
15
+ ```bash
16
+ uv run python examples/delegate.py
17
+ ```
18
+
19
+ ## Code
20
+
21
+ The bot and the dashboard both read `app.ini`. Only the dashboard reads `dashboard.ini`. The dashboard's public address lives in `app.ini`, and `dashboard.ini` refers to it:
22
+
23
+ ```python
24
+ from pathlib import Path
25
+ from typing import TypeVar
26
+
27
+ from confkit.config import Config
28
+ from confkit.ext.delegate import Delegate
29
+
30
+ T = TypeVar("T")
31
+ class AppConfig(Config[T]): ...
32
+ class DashboardConfig(Config[T]): ...
33
+
34
+ AppConfig.set_file(Path("app.ini"))
35
+ DashboardConfig.set_file(Path("dashboard.ini"))
36
+
37
+ class Settings:
38
+ dashboard_url = AppConfig("http://localhost:8001")
39
+
40
+ class DashboardSettings:
41
+ session_key = DashboardConfig("change-me")
42
+ public_url = Delegate(Settings.dashboard_url)
43
+ ```
44
+
45
+ ## Generated Files
46
+
47
+ `app.ini` holds the value:
48
+
49
+ ```ini
50
+ [Settings]
51
+ dashboard_url = http://localhost:8001
52
+ ```
53
+
54
+ `dashboard.ini` has no `public_url` key:
55
+
56
+ ```ini
57
+ [DashboardSettings]
58
+ session_key = change-me
59
+ ```
60
+
61
+ ## Notes
62
+
63
+ - Reading `DashboardSettings.public_url` reads the `dashboard_url` key in `app.ini`.
64
+ - `DashboardSettings().public_url = "https://dashboard.example.com"` writes the `dashboard_url` key in `app.ini`.
65
+ - A delegate can point at another delegate. Both read and write the same key.
66
+ - Pass a plain attribute read of a `Config` setting, like `Settings.dashboard_url` or `module.Settings.dashboard_url`. A literal or a computed value such as `Settings.dashboard_url + "/callback"` raises `TypeError`.
67
+ - `Delegate` finds the target by reading the caller's bytecode, so it runs on CPython only.
@@ -29,5 +29,6 @@ uv run python examples/basic.py
29
29
  | Enums | [Enums](enums.md) | StrEnum, IntEnum, IntFlag, optional enum values |
30
30
  | Decorators | [Decorators](decorators.md) | Injecting config into functions |
31
31
  | argparse Integration | [Argparse](argparse.md) | CLI defaults + config separation |
32
+ | Delegates | [Delegate](delegate.md) | Reading and writing a setting in another file |
32
33
 
33
34
  Select an example in the navigation to dive in.
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+ from typing import TypeVar
3
+
4
+ from confkit.config import Config
5
+ from confkit.ext.delegate import Delegate
6
+
7
+ T = TypeVar("T")
8
+ class AppConfig(Config[T]): ...
9
+ class DashboardConfig(Config[T]): ...
10
+
11
+ AppConfig.set_file(Path("app.ini"))
12
+ DashboardConfig.set_file(Path("dashboard.ini"))
13
+
14
+ class Settings:
15
+ """Read by both the bot and the dashboard."""
16
+
17
+ dashboard_url = AppConfig("http://localhost:8001")
18
+
19
+ class DashboardSettings:
20
+ """Read by the dashboard only."""
21
+
22
+ session_key = DashboardConfig("change-me")
23
+ public_url = Delegate(Settings.dashboard_url)
24
+
25
+ if __name__ == "__main__":
26
+ print("Dashboard URL:", Settings.dashboard_url)
27
+ print("Public URL:", DashboardSettings.public_url)
28
+
29
+ DashboardSettings().public_url = "https://dashboard.example.com"
30
+ print("Dashboard URL after writing the delegate:", Settings.dashboard_url)
31
+ assert Settings.dashboard_url == DashboardSettings.public_url
32
+ assert "public_url" not in Path("dashboard.ini").read_text()
@@ -17,6 +17,7 @@ nav:
17
17
  - Enums: examples/enums.md
18
18
  - Custom Data Type: examples/custom_data_type.md
19
19
  - Argparse Integration: examples/argparse.md
20
+ - Delegates: examples/delegate.md
20
21
  - API Reference:
21
22
  - Config: reference/config.md
22
23
  - Data Types: reference/data_types.md
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "confkit"
3
- version = "2.1.1"
3
+ version = "2.2.0"
4
4
  description = "Lightweight and Easy to use configuration manager for Python projects"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,15 +8,15 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
11
- "typing-extensions>=4.5.0",
11
+ "typing-extensions>=4.16.0",
12
12
  ]
13
13
 
14
14
  [project.optional-dependencies]
15
15
  pydantic = [
16
- "pydantic>=2.12.5",
16
+ "pydantic>=2.14.2",
17
17
  ]
18
18
  msgspec = [
19
- "msgspec>=0.20.0",
19
+ "msgspec>=0.22.0",
20
20
  "pyyaml>=6.0.3", # required by msgspec
21
21
  "tomli-w>=1.2.0", # required by msgspec
22
22
  ]
@@ -27,27 +27,27 @@ build-backend = "hatchling.build"
27
27
 
28
28
  [dependency-groups]
29
29
  dev = [
30
- "coverage>=7.13.4",
31
- "pytest-cov>=7.0.0",
32
- "hypothesis>=6.151.6",
33
- "pydantic>=2.12.5",
34
- "pytest>=9.0.2",
35
- "ruff>=0.15.1",
36
- "coveralls>=4.0.2",
37
- "msgspec>=0.20.0",
30
+ "coverage>=7.17.0",
31
+ "pytest-cov>=7.1.0",
32
+ "hypothesis>=6.170.14",
33
+ "pydantic>=2.14.2",
34
+ "pytest>=9.2.0",
35
+ "ruff>=0.17.3",
36
+ "coveralls>=4.1.0",
37
+ "msgspec>=0.22.0",
38
38
  "pyyaml>=6.0.3",
39
39
  "tomli-w>=1.2.0",
40
- "pytest-order>=1.3.0",
41
- "zensical>=0.0.55",
40
+ "pytest-order>=1.5.0",
41
+ "zensical>=0.0.73",
42
42
  "pdoc>=16.0.0",
43
43
  ]
44
44
  docs = [
45
45
  "mkdocs>=1.6.1",
46
- "mkdocs-material>=9.7.1",
47
- "mkdocstrings[python]>=1.0.3",
46
+ "mkdocs-material>=9.8.0",
47
+ "mkdocstrings[python]>=1.0.8",
48
48
  "mkdocs-pdoc-plugin @ git+https://github.com/spirali/mkdocs-pdoc-plugin",
49
49
  "pdoc>=16.0.0",
50
- "zensical>=0.0.55",
50
+ "zensical>=0.0.73",
51
51
  ]
52
52
 
53
53
  [[tool.pyright.executionEnvironments]]
@@ -79,7 +79,7 @@ class Config(Generic[VT]):
79
79
 
80
80
  def __init__(
81
81
  self,
82
- default: VT | None | BaseDataType[VT] = UNSET,
82
+ default: VT | BaseDataType[VT] | None = UNSET,
83
83
  *,
84
84
  optional: bool = False,
85
85
  ) -> None:
@@ -296,7 +296,7 @@ class Config(Generic[VT]):
296
296
  return inner
297
297
  return wrapper
298
298
 
299
- def _initialize_data_type(self, default: VT | None | BaseDataType[VT]) -> None:
299
+ def _initialize_data_type(self, default: VT | BaseDataType[VT] | None) -> None:
300
300
  """Initialize the data type based on the default value."""
301
301
  if not self.optional and default is not None:
302
302
  self._data_type = BaseDataType[VT].cast(default)
@@ -69,7 +69,7 @@ class BaseDataType(ABC, Generic[T]):
69
69
  raise TypeError(msg)
70
70
 
71
71
  @staticmethod
72
- def cast_optional(default: T | None | BaseDataType[T]) -> BaseDataType[T | None]:
72
+ def cast_optional(default: T | BaseDataType[T] | None) -> BaseDataType[T | None]:
73
73
  """Convert the default value to an Optional data type."""
74
74
  if default is None:
75
75
  return cast("BaseDataType[T | None]", NoneType())
@@ -0,0 +1,143 @@
1
+ """Extension to confkit adds delegate-related functionality.
2
+
3
+ A `Delegate` passes reads and writes through to a setting declared elsewhere, usually in another config file.
4
+
5
+ ```python
6
+ class Settings:
7
+ dashboard_url = AppConfig("http://localhost:8001")
8
+
9
+
10
+ class DashboardSettings:
11
+ public_url = Delegate(Settings.dashboard_url)
12
+ ```
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import dis
18
+ import sys
19
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
20
+
21
+ from confkit.config import Config
22
+
23
+ if TYPE_CHECKING:
24
+ from types import FrameType
25
+
26
+ T = TypeVar("T")
27
+
28
+ _NAME_LOADS = frozenset(
29
+ {
30
+ "LOAD_NAME",
31
+ "LOAD_GLOBAL",
32
+ "LOAD_DEREF",
33
+ "LOAD_CLASSDEREF", # 3.11, class body reading a variable of the enclosing function.
34
+ "LOAD_FROM_DICT_OR_DEREF", # 3.12+, same as above.
35
+ "LOAD_FAST",
36
+ "LOAD_FAST_CHECK",
37
+ "LOAD_FAST_BORROW",
38
+ },
39
+ )
40
+ _SKIPPED = frozenset({"PRECALL", "EXTENDED_ARG"}) # PRECALL only exists on 3.11.
41
+
42
+
43
+ class Delegate(Generic[T]):
44
+ """Descriptor that passes reads and writes through to another setting.
45
+
46
+ Pass the target setting as a plain attribute read, like `Delegate(Settings.dashboard_url)`.
47
+ `Settings.dashboard_url` evaluates to its value before `Delegate` runs, so `Delegate`
48
+ reads the caller's CPython bytecode to find the expression and looks up the `Config` descriptor it names.
49
+
50
+ The delegate stores nothing. Reading it reads the target's key, and assigning through
51
+ an instance writes the target's key, in the target's file.
52
+ A delegate of a delegate passes through to the same `Config` descriptor.
53
+ """
54
+
55
+ def __init__(self, target: T, /) -> None: # noqa: ARG002 The value is only there for type checkers.
56
+ """Find the `Config` descriptor named by the argument expression."""
57
+ owner, target_descriptor = _find_target(_caller_frame())
58
+ self._owner: type = owner
59
+ self._target: Config[T] = target_descriptor
60
+
61
+ def __get__(self, obj: object, obj_type: type | None = None) -> T:
62
+ """Read the target setting."""
63
+ return self._target.__get__(None, self._owner)
64
+
65
+ def __set__(self, obj: object, value: T) -> None:
66
+ """Write the target setting.
67
+
68
+ The target's owner class is passed on, so `Config.__set__` never touches the delegating instance.
69
+ """
70
+ self._target.__set__(self._owner, value)
71
+
72
+
73
+ def _caller_frame() -> FrameType:
74
+ """Return the frame that called `Delegate(...)`.
75
+
76
+ `Delegate[str](...)` goes through `typing`'s generic alias, which adds its own frames.
77
+ """
78
+ frame = sys._getframe(2) # noqa: SLF001 Skip this function and Delegate.__init__.
79
+ while frame.f_globals.get("__name__") == "typing" and frame.f_back is not None:
80
+ frame = frame.f_back
81
+ return frame
82
+
83
+
84
+ def _find_target(frame: FrameType) -> tuple[type, Config[Any]]:
85
+ """Find the descriptor read by the single argument of the call running in `frame`.
86
+
87
+ Walks back from the CALL instruction over the LOAD_ATTR chain to the name it starts from,
88
+ for example LOAD_NAME Settings, LOAD_ATTR dashboard_url.
89
+ """
90
+ instructions = [i for i in dis.get_instructions(frame.f_code) if i.opname not in _SKIPPED]
91
+ call = next((n for n, i in enumerate(instructions) if i.offset == frame.f_lasti), None)
92
+ attrs: list[str] = []
93
+ base: str | None = None
94
+ if call is not None and instructions[call].opname == "CALL" and instructions[call].arg == 1:
95
+ for instruction in reversed(instructions[:call]):
96
+ if instruction.opname == "LOAD_ATTR":
97
+ attrs.append(instruction.argval)
98
+ continue
99
+ if instruction.opname in _NAME_LOADS:
100
+ base = instruction.argval
101
+ break
102
+
103
+ if base is None or not attrs:
104
+ msg = "Delegate needs a setting read as its only argument, like Delegate(Settings.dashboard_url)."
105
+ raise TypeError(msg)
106
+
107
+ *path, name = reversed(attrs)
108
+ owner = _resolve_name(frame, base)
109
+ for attr in path:
110
+ owner = getattr(owner, attr)
111
+ return _lookup_descriptor(owner, name)
112
+
113
+
114
+ def _resolve_name(frame: FrameType, name: str) -> object:
115
+ """Look a name up the way the caller's code would.
116
+
117
+ A class body's frame doesn't expose the enclosing function's variables. For a free variable,
118
+ follow the frames that run the class statements out to the one that holds it.
119
+ """
120
+ scope = frame
121
+ while name not in scope.f_locals and name in scope.f_code.co_freevars and scope.f_back is not None:
122
+ scope = scope.f_back
123
+ for namespace in (scope.f_locals, frame.f_globals, frame.f_builtins):
124
+ if name in namespace:
125
+ return namespace[name]
126
+ msg = f"Delegate could not resolve {name!r}."
127
+ raise NameError(msg)
128
+
129
+
130
+ def _lookup_descriptor(owner: object, name: str) -> tuple[type, Config[Any]]:
131
+ """Return the class that declares the `Config` descriptor `name`, and the descriptor, following delegates."""
132
+ cls = owner if isinstance(owner, type) else type(owner)
133
+ for class_ in cls.__mro__:
134
+ attr = vars(class_).get(name)
135
+ if isinstance(attr, Delegate):
136
+ return attr._owner, attr._target # noqa: SLF001
137
+ if isinstance(attr, Config):
138
+ return class_, attr
139
+ msg = f"{cls.__qualname__}.{name} is not a Config setting, so Delegate can't pass through to it."
140
+ raise TypeError(msg)
141
+
142
+
143
+ __all__ = ["Delegate"]
@@ -0,0 +1,168 @@
1
+ """Tests for the Delegate extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ from confkit.config import Config
12
+ from confkit.ext.delegate import Delegate
13
+
14
+
15
+ class AppConfig(Config[str]): ...
16
+
17
+
18
+ class DashboardConfig(Config[str]): ...
19
+
20
+
21
+ app_file = Path("delegate_app_test.ini")
22
+ dashboard_file = Path("delegate_dashboard_test.ini")
23
+ app_file.unlink(missing_ok=True)
24
+ dashboard_file.unlink(missing_ok=True)
25
+ AppConfig.set_file(app_file)
26
+ DashboardConfig.set_file(dashboard_file)
27
+
28
+
29
+ class Settings:
30
+ dashboard_url = AppConfig("http://localhost:8001")
31
+
32
+
33
+ class DashboardSettings:
34
+ secret = DashboardConfig("hunter2")
35
+ public_url = Delegate(Settings.dashboard_url)
36
+
37
+
38
+ class Chained:
39
+ url = Delegate(DashboardSettings.public_url)
40
+
41
+
42
+ class Explicit:
43
+ url = Delegate[str](Settings.dashboard_url)
44
+
45
+
46
+ class Slotted:
47
+ __slots__ = ()
48
+ url = Delegate(Settings.dashboard_url)
49
+
50
+
51
+ module = SimpleNamespace(Settings=Settings)
52
+
53
+
54
+ class Dotted:
55
+ url = Delegate(module.Settings.dashboard_url)
56
+
57
+
58
+ def test_delegate_reads_target_value() -> None:
59
+ """The delegate returns the target's current value."""
60
+ assert DashboardSettings.public_url == Settings.dashboard_url
61
+ assert DashboardSettings().public_url == Settings.dashboard_url
62
+
63
+
64
+ def test_target_write_shows_through_delegate() -> None:
65
+ """Writing the target changes what the delegate reads."""
66
+ Settings().dashboard_url = "https://target.example.com"
67
+ assert DashboardSettings.public_url == "https://target.example.com"
68
+
69
+
70
+ def test_delegate_write_goes_to_target() -> None:
71
+ """Writing the delegate writes the target's file, not the delegate's."""
72
+ DashboardSettings().public_url = "https://delegate.example.com"
73
+ assert Settings.dashboard_url == "https://delegate.example.com"
74
+ assert "https://delegate.example.com" in app_file.read_text()
75
+ assert "public_url" not in dashboard_file.read_text()
76
+
77
+
78
+ def test_delegate_of_delegate_passes_through() -> None:
79
+ """A delegate of a delegate reads and writes the original setting."""
80
+ Chained().url = "https://chained.example.com"
81
+ assert Settings.dashboard_url == "https://chained.example.com"
82
+ assert DashboardSettings.public_url == "https://chained.example.com"
83
+
84
+
85
+ def test_delegate_in_function_scope() -> None:
86
+ """The target can be named through a local variable."""
87
+ owner = Settings
88
+
89
+ class Local:
90
+ url = Delegate(owner.dashboard_url)
91
+
92
+ Settings().dashboard_url = "https://local.example.com"
93
+ assert Local.url == "https://local.example.com"
94
+
95
+
96
+ def test_delegate_rejects_derived_value() -> None:
97
+ """A value computed from a setting isn't a setting, so it raises."""
98
+ with pytest.raises(TypeError, match="only argument"):
99
+ Delegate(Settings.dashboard_url + "/callback")
100
+
101
+
102
+ def test_delegate_rejects_literal() -> None:
103
+ """A literal isn't a setting, so it raises."""
104
+ with pytest.raises(TypeError, match="only argument"):
105
+ Delegate("http://not-a-setting")
106
+
107
+
108
+ def test_delegate_rejects_non_config_attribute() -> None:
109
+ """An attribute that isn't a Config descriptor raises and names it."""
110
+
111
+ class Plain:
112
+ url = "http://plain"
113
+
114
+ with pytest.raises(TypeError, match=r"Plain\.url is not a Config setting"):
115
+ Delegate(Plain.url)
116
+
117
+
118
+ def test_delegate_with_explicit_type_parameter() -> None:
119
+ """`Delegate[str](...)` finds the caller's frame past typing's generic alias."""
120
+ Settings().dashboard_url = "https://explicit.example.com"
121
+ assert Explicit.url == "https://explicit.example.com"
122
+
123
+
124
+ def test_delegate_rejects_keyword_argument() -> None:
125
+ """The target is positional-only, so Python itself rejects the keyword form."""
126
+ untyped: Any = Delegate # The type checker rejects this call too; check Python's own error.
127
+ with pytest.raises(TypeError, match="positional-only"):
128
+ untyped(target=Settings.dashboard_url)
129
+
130
+
131
+ def test_delegate_in_class_nested_in_class_in_function() -> None:
132
+ """A free variable is resolved through every enclosing class body."""
133
+ owner = Settings
134
+
135
+ class Outer:
136
+ class Inner:
137
+ url = Delegate(owner.dashboard_url)
138
+
139
+ Settings().dashboard_url = "https://nested.example.com"
140
+ assert Outer.Inner.url == "https://nested.example.com"
141
+
142
+
143
+ def test_delegate_write_leaves_delegating_instance_alone() -> None:
144
+ """Writing through a delegate sets nothing on the delegating instance, so __slots__ classes work."""
145
+ Slotted().url = "https://slotted.example.com"
146
+ assert Settings.dashboard_url == "https://slotted.example.com"
147
+
148
+ instance = DashboardSettings()
149
+ instance.public_url = "https://instance.example.com"
150
+ assert vars(instance) == {}
151
+
152
+
153
+ def test_delegate_through_attribute_chain() -> None:
154
+ """`module.Settings.dashboard_url` follows every attribute in the chain."""
155
+ Dotted().url = "https://dotted.example.com"
156
+ assert Settings.dashboard_url == "https://dotted.example.com"
157
+ assert Dotted.url == "https://dotted.example.com"
158
+
159
+
160
+ def test_delegate_read_runs_target_file_change_hook(monkeypatch: pytest.MonkeyPatch) -> None:
161
+ """A read through the delegate runs the target's file-change check, the same as a direct read."""
162
+ descriptor = vars(Settings)["dashboard_url"]
163
+ events: list[str] = []
164
+ monkeypatch.setattr(AppConfig._watcher, "has_changed", lambda: True)
165
+ monkeypatch.setattr(descriptor, "on_file_change", lambda origin, _old, _new: events.append(origin))
166
+
167
+ assert DashboardSettings.public_url == Settings.dashboard_url
168
+ assert events == ["get", "get"]
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.11"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.15'",
@@ -169,7 +169,7 @@ wheels = [
169
169
 
170
170
  [[package]]
171
171
  name = "confkit"
172
- version = "2.1.1"
172
+ version = "2.2.0"
173
173
  source = { editable = "." }
174
174
  dependencies = [
175
175
  { name = "typing-extensions" },
@@ -1286,13 +1286,8 @@ wheels = [
1286
1286
  { url = "https://files.pythonhosted.org/packages/49/5f/6d43ae25b6be241fd5056be8555de16c21a35b7d18321f4e204ebe1b0181/zensical-0.0.55-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b53d213126402fec053b59e68122198a92caec6e53ac9673e7b2c27008acccb7", size = 12893366, upload-time = "2026-08-16T10:19:23.907Z" },
1287
1287
  { url = "https://files.pythonhosted.org/packages/1f/71/df7e7a82e003da68dee0d410c510a32df67990bd4949e8e09c9260a95064/zensical-0.0.55-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbe0bdafab130bd5a9444adccf3330e056388eedb82681ffa51c6c476264688b", size = 12778731, upload-time = "2026-08-16T10:19:26.42Z" },
1288
1288
  { url = "https://files.pythonhosted.org/packages/e9/f0/c399907c672759f27e3d8031e3cdb1ff3c297f9df086d82648f573b8067e/zensical-0.0.55-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a699bc5711ddc51a7362b5875d88bcdb9eea018dd76b241920dfeff9fd14abd", size = 13225804, upload-time = "2026-08-16T10:19:28.932Z" },
1289
- { url = "https://files.pythonhosted.org/packages/e4/e5/0d50213904ef85d5412e6bcd92fe4bd88399e57bf06a28f9cba9a30a558f/zensical-0.0.55-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e5f8a734d3b1d964de4e81f257f7ddb69c79b23178df6713d56eea97e9ddbdf", size = 13159319, upload-time = "2026-08-16T10:19:31.318Z" },
1290
- { url = "https://files.pythonhosted.org/packages/66/f3/de59ba35711c9dc4a1eda435a0058245c42f72639a2399f9590c361daa7a/zensical-0.0.55-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f83fd50c61c42a9a37510843b1de07652f8ec5df8cc245382cfc2e592c7d6f83", size = 13536310, upload-time = "2026-08-16T10:19:34.026Z" },
1291
1289
  { url = "https://files.pythonhosted.org/packages/56/35/1e14d07c97bdb901bf4fd72e6fba52a65e1ff46d0a385b2fceeee2f65227/zensical-0.0.55-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7a970fe0f2512261d60f64ebcc9b9e2dbce4a96f520a4dac21a980881b9284d", size = 13192098, upload-time = "2026-08-16T10:19:36.247Z" },
1292
1290
  { url = "https://files.pythonhosted.org/packages/4f/a2/7de6478a39b23d6dbc8cd862390562d9a91641d9ca4552ed6958259e3ca0/zensical-0.0.55-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7b9b6c27080d3976ef7e2b9ac211a4f6f0a446e104a01114889a0992f571da56", size = 13403866, upload-time = "2026-08-16T10:19:38.491Z" },
1293
- { url = "https://files.pythonhosted.org/packages/73/2e/bbf25e9f3378dd2d6209dbea427cf9047cdc99533a94152c26fd33f2bb72/zensical-0.0.55-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:588b14322d55652d83de551c7fcd8a377c65753a4f393a88b359fd92738efe1e", size = 13432059, upload-time = "2026-08-16T10:19:40.931Z" },
1294
- { url = "https://files.pythonhosted.org/packages/75/5c/0400328cba6712cfce2bc12039a4184e951e4cbe1d35b12dad69b0e678d4/zensical-0.0.55-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:79e9ce4195f8d7b0a7d364f9adc1a98bdb4c5f213985edd6fa75d7e3359e8616", size = 13587117, upload-time = "2026-08-16T10:19:43.168Z" },
1295
1291
  { url = "https://files.pythonhosted.org/packages/84/fc/be21acae7d5b4ceb26f48b7dcda2ab65f2ad2fb865030aeff51b1a000d97/zensical-0.0.55-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bc8d2f8a343311f668c96aced77ca679144d62dc0f191dadbd9d7bc84e980295", size = 13535419, upload-time = "2026-08-16T10:19:45.584Z" },
1296
- { url = "https://files.pythonhosted.org/packages/c8/37/000e3a9cec7aad0087a353e913fd973dbe355ce3b6efdc1cce2d31764f2c/zensical-0.0.55-cp310-abi3-win32.whl", hash = "sha256:c63d6d925b69e5e115a96e6981e2c24194abda3757e65fb5ff843329f1b8bdfb", size = 12448614, upload-time = "2026-08-16T10:19:47.843Z" },
1297
1292
  { url = "https://files.pythonhosted.org/packages/8a/ab/f660dbc74b7acb5547c6920068516e5570965b64ae757f30847df0ab51db/zensical-0.0.55-cp310-abi3-win_amd64.whl", hash = "sha256:cbe65f11dab3f019fc047ddbf147b6f6ac8f6e2a83232c8be6d65fa7c50ee7df", size = 12712414, upload-time = "2026-08-16T10:19:50.053Z" },
1298
1293
  ]
@@ -19,6 +19,7 @@ nav = [
19
19
  { "Enums" = "examples/enums.md" },
20
20
  { "Custom Data Type" = "examples/custom_data_type.md" },
21
21
  { "Argparse Integration" = "examples/argparse.md" },
22
+ { "Delegates" = "examples/delegate.md" },
22
23
  ] },
23
24
  { "API Reference" = [
24
25
  { "Config" = "reference/config.md" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes