monkay 0.0.1__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.
@@ -0,0 +1,56 @@
1
+ name: Deploy Monkay site to Pages
2
+
3
+ on:
4
+ # Runs on pushes targeting the default branch
5
+ push:
6
+ branches: [$default-branch]
7
+
8
+ # Allows you to run this workflow manually from the Actions tab
9
+ workflow_dispatch:
10
+
11
+ # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
12
+ permissions:
13
+ contents: read
14
+ pages: write
15
+ id-token: write
16
+
17
+ # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
18
+ # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
19
+ concurrency:
20
+ group: "pages"
21
+ cancel-in-progress: false
22
+
23
+ # Default to bash
24
+ defaults:
25
+ run:
26
+ shell: bash
27
+
28
+ jobs:
29
+ # Build job
30
+ build:
31
+ runs-on: ubuntu-latest
32
+ env:
33
+ HUGO_VERSION: 0.128.0
34
+ steps:
35
+ - uses: "actions/checkout@v4"
36
+ - uses: "actions/setup-python@v5"
37
+ - name: "Install hatch"
38
+ run: "pip install hatch"
39
+ - name: Build Pages
40
+ run "hatch run docs:build"
41
+ - name: Upload artifact
42
+ uses: actions/upload-pages-artifact@v3
43
+ with:
44
+ path: ./site
45
+
46
+ # Deployment job
47
+ deploy:
48
+ environment:
49
+ name: github-pages
50
+ url: ${{ steps.deployment.outputs.page_url }}
51
+ runs-on: ubuntu-latest
52
+ needs: build
53
+ steps:
54
+ - name: Deploy to GitHub Pages
55
+ id: deployment
56
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,33 @@
1
+ name: Test Suite
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - "**"
7
+ jobs:
8
+ tests:
9
+ name: "Python ${{ matrix.python-version }}"
10
+ runs-on: "ubuntu-latest"
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: "actions/checkout@v4"
16
+ - uses: "actions/setup-python@v5"
17
+ with:
18
+ python-version: "${{ matrix.python-version }}"
19
+ allow-prereleases: true
20
+ - uses: actions/cache@v4
21
+ id: cache
22
+ with:
23
+ path: ${{ env.pythonLocation }}
24
+ key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-hatch
25
+ - name: "Install dependencies"
26
+ if: steps.cache.outputs.cache-hit != 'true'
27
+ run: "pip install hatch"
28
+ - name: "Run linting"
29
+ run: "hatch fmt --check"
30
+ - name: "Run mypy"
31
+ run: "hatch run types:check"
32
+ - name: "Run tests"
33
+ run: "hatch test"
@@ -0,0 +1,29 @@
1
+ # folders
2
+ *.egg-info/
3
+ .hypothesis/
4
+ .idea/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff
8
+ .tox/
9
+ .venv/
10
+ venv/
11
+ .vscode/
12
+ __pycache__/
13
+ virtualenv/
14
+ build/
15
+ dist/
16
+ node_modules/
17
+ results/
18
+
19
+ # files
20
+ **/*.so
21
+ *.sqlite
22
+ *.iml
23
+ .DS_Store
24
+ .coverage
25
+ .coverage.*
26
+ .python-version
27
+ coverage.*
28
+ docker-compose.override.yml
29
+ compose.override.yml
@@ -0,0 +1,7 @@
1
+ Copyright 2024 <COPYRIGHT HOLDER>
2
+
3
+ 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:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ 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.
monkay-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.3
2
+ Name: monkay
3
+ Version: 0.0.1
4
+ Summary: The ultimate preload, settings, lazy import manager.
5
+ Project-URL: Documentation, https://github.com/devkral/monkay#readme
6
+ Project-URL: Issues, https://github.com/devkral/monkay/issues
7
+ Project-URL: Source, https://github.com/devkral/monkay
8
+ Author-email: alex <devkral@web.de>
9
+ Keywords: lazy-imports,monkey-patching,settings
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: Implementation :: CPython
17
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
18
+ Requires-Python: >=3.9
19
+ Provides-Extra: settings
20
+ Requires-Dist: pydantic-settings; extra == 'settings'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # monkay
24
+
25
+ [![PyPI - Version](https://img.shields.io/pypi/v/monkay.svg)](https://pypi.org/project/monkay)
26
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/monkay.svg)](https://pypi.org/project/monkay)
27
+
28
+ -----
29
+
30
+ ## Table of Contents
31
+
32
+ - [Installation](#installation)
33
+ - [License](#license)
34
+
35
+ ## Installation
36
+
37
+ ```shell
38
+ pip install monkay
39
+ ```
40
+
41
+ ## License
42
+
43
+ `monkay` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
monkay-0.0.1/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # monkay
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/monkay.svg)](https://pypi.org/project/monkay)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/monkay.svg)](https://pypi.org/project/monkay)
5
+
6
+ -----
7
+
8
+ ## Table of Contents
9
+
10
+ - [Installation](#installation)
11
+ - [License](#license)
12
+
13
+ ## Installation
14
+
15
+ ```shell
16
+ pip install monkay
17
+ ```
18
+
19
+ ## License
20
+
21
+ `monkay` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,9 @@
1
+ # Helpers
2
+
3
+
4
+ Monkay comes with two helpers
5
+
6
+ - `load(path, allow_splits=":.")`: Load a path like Monkay. `allow_splits` allows to configure if attributes are seperated via . or :.
7
+ When both are specified, both split ways are possible (Default).
8
+ - `load_any(module_path, potential_attrs, *, non_first_deprecated=False)`: Checks for a module if any attribute name matches. Return attribute value or raises ImportError when non matches.
9
+ When `non_first_deprecated` is `True`, a DeprecationMessage is issued for the non-first attribute which matches. This can be handy for deprecating module interfaces.
@@ -0,0 +1,20 @@
1
+ # Home
2
+
3
+ ## What is Monkay for?
4
+
5
+ Imagine a large software project which evolves. Old names should be deprecated. Imports
6
+ should be lazy so sideeffects are minimized.
7
+ But on the other hand you have self-registering parts like extensions or like Django models.
8
+
9
+ Multiple threads access application parts and tests with different settings are also a requirement
10
+ now things get really complicated.
11
+
12
+ This project solves the problems.
13
+ Monkay is a monkey-patcher with async features, preload and extension support (and some more).
14
+ Extension registrations can be reordered so there are also no dependency issues and extensions can build on each other.
15
+ Tests are possible by an async friendly approach via context variables so every situation can be easily tested.
16
+
17
+ For application frameworks Monkay provides settings which can also temporarily overwritten like in Django and
18
+ optionally setting names for preloads and extensions.
19
+
20
+ You may want to continue to the [Tutorial](tutorial.md)
@@ -0,0 +1,5 @@
1
+ # Release notes
2
+
3
+ ## Version 0.0.1
4
+
5
+ Initial release
@@ -0,0 +1,230 @@
1
+ # Tutorial
2
+
3
+ ## How to use
4
+
5
+ ### Installation
6
+
7
+ ``` shell
8
+ pip install monkay
9
+ # or
10
+ # pip install monkay[settings]
11
+ ```
12
+
13
+ ### Usage
14
+
15
+ Probably in the main `__init__.py` you define something like this:
16
+
17
+ ``` python
18
+ monkay = Monkay(
19
+ # required for autohooking
20
+ globals(),
21
+ with_extensions=True,
22
+ with_instance=True,
23
+ settings_path="settings_path:Settings",
24
+ preloads=["tests.targets.module_full_preloaded1:load"],
25
+ settings_preload_name="preloads",
26
+ settings_extensions_name="extensions",
27
+ lazy_imports={"bar": "tests.targets.fn_module:bar"},
28
+ deprecated_lazy_imports={
29
+ "deprecated": {
30
+ "path": "tests.targets.fn_module:deprecated",
31
+ "reason": "old",
32
+ "new_attribute": "super_new",
33
+ }
34
+ },
35
+ )
36
+ ```
37
+
38
+
39
+ When providing your own `__all__` variable **after** providing Monkay or you want more controll, you can provide
40
+
41
+ `skip_all_update=True`
42
+
43
+ and update the `__all__` value via `Monkay.update_all_var` if wanted.
44
+
45
+ #### Using settings
46
+
47
+ Settings can be an initialized pydantic settings variable or a class.
48
+ When pointing to a class the class is automatically called without arguments.
49
+
50
+ Let's do the configuration like Django via environment variable:
51
+
52
+ ``` python title="__init__.py"
53
+ import os
54
+ monkay = Monkay(
55
+ globals(),
56
+ with_extensions=True,
57
+ with_instance=True,
58
+ settings_path=os.environ.get("MONKAY_SETTINGS", "example.default.path.settings:Settings"),
59
+ settings_preload_name="preloads",
60
+ settings_extensions_name="extensions",
61
+ )
62
+ ```
63
+
64
+ ``` python title="settings.py"
65
+ from pydantic_settings import BaseSettings
66
+
67
+ class Settings(BaseSettings):
68
+ preloads: list[str] = []
69
+ extensions: list[Any] = []
70
+
71
+ ```
72
+
73
+ And voila settings are now available from monkay.settings. This works only when all settings arguments are
74
+ set via environment or defaults.
75
+
76
+ When having explicit variables this is also possible:
77
+
78
+ ``` python title="explicit_settings.py"
79
+ from pydantic_settings import BaseSettings
80
+
81
+ class Settings(BaseSettings):
82
+ preloads: list[str]
83
+ extensions: list[Any]
84
+
85
+ settings = Settings(preloads=[], extensions=[])
86
+ ```
87
+ Note here the lowercase settings
88
+
89
+ ``` python title="__init__.py"
90
+ import os
91
+ from monkay import Monkay
92
+ monkay = Monkay(
93
+ globals(),
94
+ with_extensions=True,
95
+ with_instance=True,
96
+ settings_path=os.environ.get("MONKAY_SETTINGS", "example.default.path.settings:settings"),
97
+ settings_preload_name="preloads",
98
+ settings_extensions_name="extensions",
99
+ )
100
+ ```
101
+
102
+ #### Pathes
103
+
104
+ Like shown in the examples pathes end with a `:` for an attribute. But sometimes a dot is nicer.
105
+ This is why you can also use a dot in most cases. A notable exception are preloads where `:` are marking loading functions.
106
+
107
+ #### Preloads
108
+
109
+ Preloads are required in case some parts of the application are self-registering but no extensions.
110
+
111
+ There are two kinds of preloads
112
+
113
+ 1. Module preloads. Simply a module is imported via `import_module`. Self-registrations are executed
114
+ 2. Functional preloads. With a `:`. The function name behind the `:` is executed and it is
115
+ expected that the function does the preloading. The module however is still preloaded.
116
+
117
+
118
+ ``` python title="preloader.py"
119
+ from importlib import import_module
120
+
121
+ def preloader():
122
+ for i in ["foo.bar", "foo.err"]:
123
+ import_module(i)
124
+
125
+ ```
126
+
127
+ ``` python title="settings.py"
128
+ from pydantic_settings import BaseSettings
129
+
130
+ class Settings(BaseSettings):
131
+ preloads: list[str] = ["preloader:preloader"]
132
+ ```
133
+
134
+ ##### Lazy imports
135
+
136
+ When using lazy imports the globals get an `__getattr__` injected. A potential old `__getattr__` is used as fallback when provided **before**
137
+ initializing the Monkay instance:
138
+
139
+ `module attr > monkay __getattr__ > former __getattr__ or Error`.
140
+
141
+
142
+ Lazy imports of the `lazy_imports` parameter/attribute are defined in a dict with the key as the pseudo attribute and the value the forward.
143
+
144
+ There are also `deprecated_lazy_imports` which have as value a dictionary with the key-values
145
+
146
+ - `path`: Forward path.
147
+ - `reason` (Optional): Deprecation reason.
148
+ - `new_attribute` (Optional): Upgrade path.
149
+
150
+ #### Using the instance feature
151
+
152
+ The instance feature is activated by providing a boolean (or a string for an explicit name) to the `with_instance`
153
+ parameter.
154
+
155
+ For entrypoints you can set now the instance via `set_instance`. A good entrypoint is the init and using the settings:
156
+
157
+
158
+ ``` python title="__init__.py"
159
+ import os
160
+ from monkay import Monkay, load
161
+
162
+ monkay = Monkay(
163
+ globals(),
164
+ with_extensions=True,
165
+ with_instance=True,
166
+ settings_path=os.environ.get("MONKAY_SETTINGS", "example.default.path.settings:settings"),
167
+ settings_preload_name="preloads",
168
+ settings_extensions_name="extensions",
169
+ )
170
+
171
+ monkay.set_instance(load(settings.APP_PATH))
172
+ ```
173
+
174
+ #### Using the extensions feature
175
+
176
+ Extensions work well together with the instances features.
177
+
178
+ An extension is a class implementing the ExtensionProtocol:
179
+
180
+ ``` python title="Extension protocol"
181
+ from typing import Protocol
182
+
183
+ @runtime_checkable
184
+ class ExtensionProtocol(Protocol[L]):
185
+ name: str
186
+
187
+ def apply(self, monkay_instance: Monkay[L]) -> None: ...
188
+
189
+ ```
190
+
191
+
192
+ A name (can be dynamic) and the apply method are required. The instance itself is easily retrieved from
193
+ the monkay instance.
194
+
195
+ ``` python title="settings.py"
196
+ from dataclasses import dataclass
197
+ import copy
198
+ from pydantic_settings import BaseSettings
199
+
200
+ class App:
201
+ extensions: list[Any]
202
+
203
+ @dataclass
204
+ class Extension:
205
+ name: str = "hello"
206
+
207
+ def apply(self, monkay_instance: Monkay) -> None:
208
+ monkay_instance.instance.extensions.append(copy.copy(self))
209
+
210
+ class Settings(BaseSettings):
211
+ preloads: list[str] = ["preloader:preloader"]
212
+ extensions: list[Any] = [Extension]
213
+ APP_PATH: str = "settings.App"
214
+
215
+ ```
216
+
217
+ ##### Reordering extension order dynamically
218
+
219
+ During apply it is possible to call `monkay.ensure_extension(name | Extension)`. When providing an extension
220
+ it is automatically initialized though not added to extensions.
221
+ Every name is called once and extensions in `monkay.extensions` have priority. They will applied instead when providing
222
+ a same named extension via ensure_extension.
223
+
224
+ ##### Reordering extension order dynamically2
225
+
226
+ There is a second more complicated way to reorder:
227
+
228
+ via the parameter `extension_order_key_fn`. It takes a key function which is expected to return a lexicographic key capable for ordering.
229
+
230
+ You can however intermix both.
@@ -0,0 +1,8 @@
1
+ site_name: Monkay
2
+ site_description: The ultimate preload, settings, lazy import manager..
3
+ site_url: https://devkral.github.io/monkay
4
+
5
+ nav:
6
+ - Home: index.md
7
+ - Tutorial: tutorial.md
8
+ - Helpers: helpers.md
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2024-present alex <devkral@web.de>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.0.1"
@@ -0,0 +1,13 @@
1
+ # SPDX-FileCopyrightText: 2024-present alex <devkral@web.de>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from .base import DeprecatedImport, ExtensionProtocol, Monkay, load, load_any
6
+
7
+ __all__ = [
8
+ "Monkay",
9
+ "DeprecatedImport",
10
+ "ExtensionProtocol",
11
+ "load",
12
+ "load_any",
13
+ ]
@@ -0,0 +1,354 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from collections.abc import Callable, Generator, Iterable, Sequence
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from functools import cached_property, partial
8
+ from importlib import import_module
9
+ from inspect import isclass
10
+ from itertools import chain
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Generic,
15
+ Protocol,
16
+ TypedDict,
17
+ TypeVar,
18
+ cast,
19
+ runtime_checkable,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from pydantic_settings import BaseSettings
24
+
25
+ L = TypeVar("L")
26
+
27
+
28
+ class DeprecatedImport(TypedDict):
29
+ path: str
30
+ reason: str
31
+ new_attribute: str
32
+
33
+
34
+ def load(path: str, allow_splits: str = ":.") -> Any:
35
+ splitted = path.rsplit(":", 1) if ":" in allow_splits else []
36
+ if len(splitted) < 2 and "." in allow_splits:
37
+ splitted = path.rsplit(".", 1)
38
+ if len(splitted) != 2:
39
+ raise ValueError(f"invalid path: {path}")
40
+ module = import_module(splitted[0])
41
+ return getattr(module, splitted[1])
42
+
43
+
44
+ def load_any(
45
+ path: str, attrs: Sequence[str], *, non_first_deprecated: bool = False
46
+ ) -> Any | None:
47
+ module = import_module(path)
48
+ first_name: None | str = None
49
+
50
+ for attr in attrs:
51
+ if hasattr(module, attr):
52
+ if non_first_deprecated and first_name is not None:
53
+ warnings.warn(
54
+ f'"{attr}" is deprecated, use "{first_name}" instead.',
55
+ DeprecationWarning,
56
+ stacklevel=2,
57
+ )
58
+ return getattr(module, attr)
59
+ if first_name is None:
60
+ first_name = attr
61
+ raise ImportError(f"Could not import any of the attributes:.{', '.join(attrs)}")
62
+
63
+
64
+ @runtime_checkable
65
+ class ExtensionProtocol(Protocol[L]):
66
+ name: str
67
+
68
+ def apply(self, monkay_instance: Monkay[L]) -> None: ...
69
+
70
+
71
+ def _stub_previous_getattr(name: str) -> Any:
72
+ raise AttributeError(f'Module has no attribute: "{name}" (Monkay).')
73
+
74
+
75
+ class Monkay(Generic[L]):
76
+ _instance: None | L = None
77
+ _instance_var: ContextVar[L | None] | None = None
78
+ # extensions are pretended to always exist, we check the _extensions_var
79
+ _extensions: dict[str, ExtensionProtocol[L]]
80
+ _extensions_var: None | ContextVar[None | dict[str, ExtensionProtocol[L]]] = None
81
+ _extensions_applied: None | ContextVar[dict[str, ExtensionProtocol[L]] | None] = (
82
+ None
83
+ )
84
+ _settings_var: ContextVar[BaseSettings | None] | None = None
85
+
86
+ def __init__(
87
+ self,
88
+ global_dict: dict,
89
+ *,
90
+ with_instance: str | bool = False,
91
+ with_extensions: str | bool = False,
92
+ extension_order_key_fn: None | Callable[[ExtensionProtocol[L]], Any] = None,
93
+ settings_path: str = "",
94
+ preloads: Iterable[str] = (),
95
+ settings_preload_name: str = "",
96
+ settings_extensions_name: str = "",
97
+ lazy_imports: dict[str, str] | None = None,
98
+ deprecated_lazy_imports: dict[str, DeprecatedImport] | None = None,
99
+ settings_ctx_name: str = "monkay_settings_ctx",
100
+ extensions_applied_ctx_name: str = "monkay_extensions_applied_ctx",
101
+ skip_all_update: bool = False,
102
+ ) -> None:
103
+ if with_instance is True:
104
+ with_instance = "monkay_instance_ctx"
105
+ with_instance = with_instance
106
+ if with_extensions is True:
107
+ with_extensions = "monkay_extensions_ctx"
108
+ with_extensions = with_extensions
109
+
110
+ self._cache_imports: dict[str, Any] = {}
111
+ self.lazy_imports = lazy_imports or {}
112
+ self.deprecated_lazy_imports = deprecated_lazy_imports or {}
113
+ assert set(
114
+ self.lazy_imports
115
+ ).isdisjoint(
116
+ self.deprecated_lazy_imports
117
+ ), f"Lazy imports and lazy deprecated imports share: {', '.join(set(self.lazy_imports).intersection(self.deprecated_lazy_imports))}"
118
+ self.settings_path = settings_path
119
+ if self.settings_path:
120
+ self._settings_var = global_dict[settings_ctx_name] = ContextVar(
121
+ settings_ctx_name, default=None
122
+ )
123
+
124
+ self.settings_preload_name = settings_preload_name
125
+ self.settings_extensions_name = settings_extensions_name
126
+
127
+ self._handle_preloads(preloads)
128
+ if self.lazy_imports or self.deprecated_lazy_imports:
129
+ getter: Callable[..., Any] = self.module_getter
130
+ if "__getattr__" in global_dict:
131
+ getter = partial(getter, chained_getter=global_dict["__getattr__"])
132
+ global_dict["__getattr__"] = getter
133
+ if not skip_all_update:
134
+ all_var = global_dict.setdefault("__all__", [])
135
+ global_dict["__all__"] = self.update_all_var(all_var)
136
+ if with_instance:
137
+ self._instance_var = global_dict[with_instance] = ContextVar(
138
+ with_instance, default=None
139
+ )
140
+ if with_extensions:
141
+ self.extension_order_key_fn = extension_order_key_fn
142
+ self._extensions = {}
143
+ self._extensions_var = global_dict[with_extensions] = ContextVar(
144
+ with_extensions, default=None
145
+ )
146
+ self._extensions_applied_var = global_dict[extensions_applied_ctx_name] = (
147
+ ContextVar(extensions_applied_ctx_name, default=None)
148
+ )
149
+ self._handle_extensions()
150
+
151
+ @property
152
+ def instance(self) -> L | None:
153
+ assert self._instance_var is not None, "Monkay not enabled for instances"
154
+ instance: L | None = self._instance_var.get()
155
+ if instance is None:
156
+ instance = self._instance
157
+ return instance
158
+
159
+ def set_instance(
160
+ self,
161
+ instance: L,
162
+ apply_extensions: bool = True,
163
+ use_extension_overwrite: bool = True,
164
+ ) -> None:
165
+ assert self._instance_var is not None, "Monkay not enabled for instances"
166
+ # need to address before the instance is swapped
167
+ if apply_extensions and self._extensions_applied_var.get() is not None:
168
+ raise RuntimeError("Other apply process in the same context is active.")
169
+ self._instance = instance
170
+ if apply_extensions and self._extensions_var is not None:
171
+ self.apply_extensions(use_overwrite=use_extension_overwrite)
172
+
173
+ @contextmanager
174
+ def with_instance(
175
+ self,
176
+ instance: L | None,
177
+ apply_extensions: bool = False,
178
+ use_extension_overwrite: bool = True,
179
+ ) -> Generator:
180
+ assert self._instance_var is not None, "Monkay not enabled for instances"
181
+ # need to address before the instance is swapped
182
+ if apply_extensions and self._extensions_applied_var.get() is not None:
183
+ raise RuntimeError("Other apply process in the same context is active.")
184
+ token = self._instance_var.set(instance)
185
+ try:
186
+ if apply_extensions and self._extensions_var is not None:
187
+ self.apply_extensions(use_overwrite=use_extension_overwrite)
188
+ yield
189
+ finally:
190
+ self._instance_var.reset(token)
191
+
192
+ def apply_extensions(self, use_overwrite: bool = True) -> None:
193
+ assert self._extensions_var is not None, "Monkay not enabled for extensions"
194
+ extensions: dict[str, ExtensionProtocol[L]] | None = (
195
+ self._extensions_var.get() if use_overwrite else None
196
+ )
197
+ if extensions is None:
198
+ extensions = self._extensions
199
+ extensions_applied = self._extensions_applied_var.get()
200
+ if extensions_applied is not None:
201
+ raise RuntimeError("Other apply process in the same context is active.")
202
+ extensions_ordered: Iterable[tuple[str, ExtensionProtocol[L]]] = cast(
203
+ dict[str, ExtensionProtocol[L]], extensions
204
+ ).items()
205
+
206
+ if self.extension_order_key_fn is not None:
207
+ extensions_ordered = sorted(
208
+ extensions_ordered,
209
+ key=self.extension_order_key_fn, # type: ignore
210
+ )
211
+ extensions_applied = set()
212
+ token = self._extensions_applied_var.set(extensions_applied)
213
+ try:
214
+ for name, extension in extensions_ordered:
215
+ if name in extensions_applied:
216
+ continue
217
+ extensions_applied.add(name)
218
+ extension.apply(self)
219
+ finally:
220
+ self._extensions_applied_var.reset(token)
221
+
222
+ def ensure_extension(self, name_or_extension: str | ExtensionProtocol[L]) -> None:
223
+ assert self._extensions_var is not None, "Monkay not enabled for extensions"
224
+ extensions: dict[str, ExtensionProtocol[L]] | None = self._extensions_var.get()
225
+ if extensions is None:
226
+ extensions = self._extensions
227
+ if isinstance(name_or_extension, str):
228
+ name = name_or_extension
229
+ extension = extensions.get(name)
230
+ elif not isclass(name_or_extension) and isinstance(
231
+ name_or_extension, ExtensionProtocol
232
+ ):
233
+ name = name_or_extension.name
234
+ extension = extensions.get(name, name_or_extension)
235
+ else:
236
+ raise RuntimeError(
237
+ 'Provided extension "{name_or_extension}" does not implement the ExtensionProtocol'
238
+ )
239
+ if name in self._extensions_applied_var.get():
240
+ return
241
+
242
+ if extension is None:
243
+ raise RuntimeError(f'Extension: "{name}" does not exist.')
244
+ self._extensions_applied_var.get().add(name)
245
+ extension.apply(self)
246
+
247
+ def add_extension(
248
+ self,
249
+ extension: ExtensionProtocol[L]
250
+ | type[ExtensionProtocol[L]]
251
+ | Callable[[], ExtensionProtocol[L]],
252
+ use_overwrite: bool = True,
253
+ ) -> None:
254
+ assert self._extensions_var is not None, "Monkay not enabled for extensions"
255
+ extensions: dict[str, ExtensionProtocol[L]] | None = (
256
+ self._extensions_var.get() if use_overwrite else None
257
+ )
258
+ if extensions is None:
259
+ extensions = self._extensions
260
+ if callable(extension) or isclass(extension):
261
+ extension = extension()
262
+ if not isinstance(extension, ExtensionProtocol):
263
+ raise ValueError(f"Extension {extension} is not compatible")
264
+ extensions[extension.name] = extension
265
+
266
+ @contextmanager
267
+ def with_extensions(
268
+ self,
269
+ extensions: dict[str, ExtensionProtocol[L]] | None,
270
+ apply_extensions: bool = False,
271
+ ) -> Generator:
272
+ assert self._extensions_var is not None, "Monkay not enabled for extensions"
273
+ token = self._extensions_var.set(extensions)
274
+ try:
275
+ yield
276
+ finally:
277
+ self._extensions_var.reset(token)
278
+
279
+ def update_all_var(self, all_var: Sequence[str]) -> list[str]:
280
+ if not isinstance(all_var, list):
281
+ all_var = list(all_var)
282
+ all_var_set = set(all_var)
283
+ if self.lazy_imports or self.deprecated_lazy_imports:
284
+ for var in chain(
285
+ self.lazy_imports,
286
+ self.deprecated_lazy_imports,
287
+ ):
288
+ if var not in all_var_set:
289
+ all_var.append(var)
290
+ return all_var
291
+
292
+ @cached_property
293
+ def _settings(self) -> BaseSettings:
294
+ settings: Any = load(self.settings_path)
295
+ if isclass(settings):
296
+ settings = settings()
297
+ return settings
298
+
299
+ @property
300
+ def settings(self) -> BaseSettings:
301
+ assert self._settings_var is not None, "Monkay not enabled for settings"
302
+ settings = self._settings_var.get()
303
+ if settings is None:
304
+ settings = self._settings
305
+ return settings
306
+
307
+ @contextmanager
308
+ def with_settings(self, settings: BaseSettings | None) -> Generator:
309
+ assert self._settings_var is not None, "Monkay not enabled for settings"
310
+ token = self._settings_var.set(settings)
311
+ try:
312
+ yield
313
+ finally:
314
+ self._settings_var.reset(token)
315
+
316
+ def module_getter(
317
+ self, key: str, *, chained_getter: Callable[[str], Any] = _stub_previous_getattr
318
+ ) -> Any:
319
+ lazy_import = self.lazy_imports.get(key)
320
+ if lazy_import is None:
321
+ deprecated = self.deprecated_lazy_imports.get(key)
322
+ if deprecated is not None:
323
+ lazy_import = deprecated["path"]
324
+ warn_strs = [f'Attribute: "{key}" is deprecated.']
325
+ if deprecated.get("reason"):
326
+ warn_strs.append(f"Reason: {deprecated["reason"]}.")
327
+ if deprecated.get("new_attribute"):
328
+ warn_strs.append(f'Use "{deprecated["new_attribute"]}" instead.')
329
+ warnings.warn("\n".join(warn_strs), DeprecationWarning, stacklevel=2)
330
+
331
+ if lazy_import is None:
332
+ return chained_getter(key)
333
+ if key not in self._cache_imports:
334
+ self._cache_imports[key] = load(lazy_import)
335
+ return self._cache_imports[key]
336
+
337
+ def _handle_preloads(self, preloads: Iterable[str]) -> None:
338
+ if self.settings_preload_name:
339
+ preloads = chain(
340
+ preloads, getattr(self.settings, self.settings_preload_name)
341
+ )
342
+ for preload in preloads:
343
+ splitted = preload.rsplit(":", 1)
344
+ try:
345
+ module = import_module(splitted[0])
346
+ except ImportError:
347
+ module = None
348
+ if module is not None and len(splitted) == 2:
349
+ getattr(module, splitted[1])()
350
+
351
+ def _handle_extensions(self) -> None:
352
+ if self.settings_extensions_name:
353
+ for extension in getattr(self.settings, self.settings_extensions_name):
354
+ self.add_extension(extension, use_overwrite=False)
@@ -0,0 +1,105 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "monkay"
7
+ dynamic = ["version"]
8
+ description = 'The ultimate preload, settings, lazy import manager.'
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ keywords = ["monkey-patching", "settings", "lazy-imports"]
13
+ authors = [
14
+ { name = "alex", email = "devkral@web.de" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ "Programming Language :: Python :: Implementation :: PyPy",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ settings = [
30
+ "pydantic-settings"
31
+ ]
32
+
33
+ [project.urls]
34
+ Documentation = "https://github.com/devkral/monkay#readme"
35
+ Issues = "https://github.com/devkral/monkay/issues"
36
+ Source = "https://github.com/devkral/monkay"
37
+
38
+ [tool.hatch.version]
39
+ path = "monkay/__about__.py"
40
+
41
+ [tool.hatch.envs.default]
42
+ dependencies = [
43
+ "pydantic_settings"
44
+ ]
45
+
46
+ [tool.hatch.envs.docs]
47
+ dependencies = [
48
+ "mkdocs",
49
+ ]
50
+ [tool.hatch.envs.docs.scripts]
51
+ build = "mkdocs build"
52
+ serve = "mkdocs serve --dev-addr localhost:8000"
53
+
54
+
55
+ [tool.hatch.envs.types]
56
+ extra-dependencies = [
57
+ "mypy>=1.0.0",
58
+ ]
59
+ [tool.hatch.envs.types.scripts]
60
+ check = "mypy --install-types --non-interactive {args:monkay tests}"
61
+
62
+
63
+ [tool.hatch.envs.hatch-test]
64
+ extra-dependencies = [
65
+ "click",
66
+ "pydantic_settings"
67
+ ]
68
+
69
+
70
+ [tool.coverage.run]
71
+ source_pkgs = ["monkay", "tests"]
72
+ branch = true
73
+ parallel = true
74
+ omit = [
75
+ "monkay/__about__.py",
76
+ ]
77
+
78
+ [tool.coverage.paths]
79
+ monkay = ["monkay", "*/monkay/monkay"]
80
+ tests = ["tests", "*/monkay/tests"]
81
+
82
+ [tool.coverage.report]
83
+ exclude_lines = [
84
+ "no cov",
85
+ "if __name__ == .__main__.:",
86
+ "if TYPE_CHECKING:",
87
+ ]
88
+
89
+ [ruff]
90
+ line-length = 99
91
+ fix = true
92
+
93
+ [tool.ruff.lint]
94
+ select = ["E", "W", "F", "C", "B", "I", "UP", "SIM"]
95
+ ignore = ["E501", "B008", "C901", "B026", "SIM115"]
96
+
97
+ [tool.ruff.lint.pycodestyle]
98
+ max-line-length = 99
99
+ max-doc-length = 120
100
+
101
+ [[tool.mypy.overrides]]
102
+ module = "tests.*"
103
+ ignore_missing_imports = true
104
+ check_untyped_defs = true
105
+ ignore_errors = true
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2024-present alex <devkral@web.de>
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass
2
+
3
+ from monkay import Monkay
4
+
5
+
6
+ @dataclass
7
+ class Extension:
8
+ name: str = "default"
9
+
10
+ def apply(self, app: Monkay) -> None:
11
+ assert isinstance(app, Monkay)
12
+ assert app.instance.is_fake_app
13
+ print(f"{self.name} called")
14
+
15
+
16
+ @dataclass
17
+ class BrokenExtension1:
18
+ name: str = "broken1"
19
+
20
+ def apply(self, app: Monkay) -> None:
21
+ app.ensure_extension("non-existent")
22
+
23
+
24
+ @dataclass
25
+ class BrokenExtension2:
26
+ name: str = "broken2"
27
+
28
+ def apply(self, app: Monkay) -> None:
29
+ # not allowed here
30
+ app.apply_extensions()
31
+
32
+
33
+ @dataclass
34
+ class NonExtension:
35
+ name: str
@@ -0,0 +1,6 @@
1
+ def bar():
2
+ return "bar"
3
+
4
+
5
+ def deprecated():
6
+ return "deprecated"
@@ -0,0 +1,34 @@
1
+ from monkay import Monkay
2
+
3
+ extras = {"foo": lambda: "foo"}
4
+
5
+
6
+ def __getattr__(name: str):
7
+ try:
8
+ return extras[name]
9
+ except KeyError as exc:
10
+ raise AttributeError from exc
11
+
12
+
13
+ class FakeApp:
14
+ is_fake_app: bool = True
15
+ pass
16
+
17
+
18
+ monkay = Monkay(
19
+ globals(),
20
+ with_extensions=True,
21
+ with_instance=True,
22
+ settings_path="tests.targets.settings:Settings",
23
+ preloads=["tests.targets.module_full_preloaded1:load"],
24
+ settings_preload_name="preloads",
25
+ settings_extensions_name="extensions",
26
+ lazy_imports={"bar": "tests.targets.fn_module:bar"},
27
+ deprecated_lazy_imports={
28
+ "deprecated": {
29
+ "path": "tests.targets.fn_module:deprecated",
30
+ "reason": "old",
31
+ "new_attribute": "super_new",
32
+ }
33
+ },
34
+ )
@@ -0,0 +1,2 @@
1
+ def load():
2
+ from . import module_full_preloaded1_fn # noqa
File without changes
@@ -0,0 +1,20 @@
1
+ from typing import Any
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+ from monkay import load
6
+
7
+
8
+ class SettingsExtension:
9
+ name: str = "settings_extension2"
10
+
11
+ def apply(self, app: Any) -> None:
12
+ print(f"{self.name} called")
13
+
14
+
15
+ class Settings(BaseSettings):
16
+ preloads: list[str] = ["tests.targets.module_preloaded1"]
17
+ extensions: list[Any] = [
18
+ lambda: load("tests.targets.extension:Extension")(name="settings_extension1"),
19
+ SettingsExtension,
20
+ ]
@@ -0,0 +1,152 @@
1
+ import contextlib
2
+ import sys
3
+ from io import StringIO
4
+
5
+ import pytest
6
+
7
+ from monkay import Monkay, load, load_any
8
+
9
+
10
+ @pytest.fixture(autouse=True, scope="function")
11
+ def cleanup():
12
+ for name in [
13
+ "module_full_preloaded1_fn",
14
+ "module_full_preloaded1",
15
+ "module_preloaded1",
16
+ "module_full",
17
+ "fn_module",
18
+ ]:
19
+ sys.modules.pop(f"tests.targets.{name}", None)
20
+ yield
21
+
22
+
23
+ def test_preloaded():
24
+ assert "tests.targets.module_full" not in sys.modules
25
+ import tests.targets.module_full as mod
26
+
27
+ assert "tests.targets.fn_module" not in sys.modules
28
+
29
+ assert "tests.targets.module_full" in sys.modules
30
+ assert "tests.targets.module_full_preloaded1" in sys.modules
31
+ assert "tests.targets.module_full_preloaded1_fn" in sys.modules
32
+ assert "tests.targets.module_preloaded1" in sys.modules
33
+ assert "tests.targets.extension" in sys.modules
34
+
35
+ with contextlib.redirect_stdout(StringIO()):
36
+ mod.bar # noqa
37
+
38
+ assert "tests.targets.fn_module" in sys.modules
39
+
40
+
41
+ def test_attrs():
42
+ import tests.targets.module_full as mod
43
+
44
+ assert isinstance(mod.monkay, Monkay)
45
+
46
+ assert mod.foo() == "foo"
47
+ assert mod.bar() == "bar"
48
+ with pytest.warns(DeprecationWarning) as record:
49
+ assert mod.deprecated() == "deprecated"
50
+ assert (
51
+ record[0].message.args[0]
52
+ == 'Attribute: "deprecated" is deprecated.\nReason: old.\nUse "super_new" instead.'
53
+ )
54
+
55
+
56
+ def test_load_any():
57
+ assert load_any("tests.targets.fn_module", ["not_existing", "bar"]) is not None
58
+ with pytest.warns(DeprecationWarning) as records:
59
+ assert (
60
+ load_any(
61
+ "tests.targets.fn_module",
62
+ ["not_existing", "bar"],
63
+ non_first_deprecated=True,
64
+ )
65
+ is not None
66
+ )
67
+ assert (
68
+ load_any(
69
+ "tests.targets.fn_module",
70
+ ["bar", "not_existing"],
71
+ non_first_deprecated=True,
72
+ )
73
+ is not None
74
+ )
75
+ assert str(records[0].message) == '"bar" is deprecated, use "not_existing" instead.'
76
+ with pytest.raises(ImportError):
77
+ assert load_any("tests.targets.fn_module", ["not-existing"]) is None
78
+ with pytest.raises(ImportError):
79
+ assert load_any("tests.targets.fn_module", []) is None
80
+ with pytest.raises(ImportError):
81
+ load_any("tests.targets.not_existing", ["bar"])
82
+
83
+
84
+ def test_extensions(capsys):
85
+ import tests.targets.module_full as mod
86
+ from tests.targets.extension import NonExtension
87
+
88
+ captured = capsys.readouterr()
89
+ assert captured.out == captured.err == ""
90
+
91
+ app = mod.FakeApp()
92
+ mod.monkay.set_instance(app)
93
+ captured_out = capsys.readouterr().out
94
+ assert captured_out == "settings_extension1 called\nsettings_extension2 called\n"
95
+ with pytest.raises(ValueError):
96
+ mod.monkay.add_extension(NonExtension(name="foo")) # type: ignore
97
+ assert capsys.readouterr().out == ""
98
+
99
+ # order
100
+
101
+ class ExtensionA:
102
+ name: str = "A"
103
+
104
+ def apply(self, monkay: Monkay) -> None:
105
+ monkay.ensure_extension("B")
106
+ with pytest.raises(RuntimeError):
107
+ monkay.ensure_extension("D")
108
+ print("A")
109
+
110
+ class ExtensionB:
111
+ name: str = "B"
112
+
113
+ def apply(self, monkay: Monkay) -> None:
114
+ monkay.ensure_extension("A")
115
+ monkay.ensure_extension(ExtensionC())
116
+ print("B")
117
+
118
+ class ExtensionC:
119
+ name: str = "C"
120
+
121
+ def apply(self, monkay: Monkay) -> None:
122
+ monkay.ensure_extension(ExtensionA())
123
+ print("C")
124
+
125
+ with mod.monkay.with_extensions({"B": ExtensionB(), "A": ExtensionA()}):
126
+ mod.monkay.apply_extensions()
127
+
128
+ assert capsys.readouterr().out == "A\nC\nB\n"
129
+ with mod.monkay.with_extensions(
130
+ {
131
+ "C": ExtensionC(),
132
+ "B": ExtensionB(),
133
+ }
134
+ ):
135
+ mod.monkay.apply_extensions()
136
+
137
+ assert capsys.readouterr().out == "B\nA\nC\n"
138
+
139
+
140
+ def test_app(capsys):
141
+ import tests.targets.module_full as mod
142
+
143
+ app = mod.FakeApp()
144
+ mod.monkay.set_instance(app)
145
+ assert mod.monkay.instance is app
146
+ captured_out = capsys.readouterr().out
147
+ assert captured_out == "settings_extension1 called\nsettings_extension2 called\n"
148
+ app2 = mod.FakeApp()
149
+ with mod.monkay.with_instance(app2):
150
+ assert mod.monkay.instance is app2
151
+ assert capsys.readouterr().out == ""
152
+ assert capsys.readouterr().out == ""