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.
- monkay-0.0.1/.github/workflows/docs_build.yml. +56 -0
- monkay-0.0.1/.github/workflows/tests.yml +33 -0
- monkay-0.0.1/.gitignore +29 -0
- monkay-0.0.1/LICENSE.txt +7 -0
- monkay-0.0.1/PKG-INFO +43 -0
- monkay-0.0.1/README.md +21 -0
- monkay-0.0.1/docs/helpers.md +9 -0
- monkay-0.0.1/docs/index.md +20 -0
- monkay-0.0.1/docs/release-notes.md +5 -0
- monkay-0.0.1/docs/tutorial.md +230 -0
- monkay-0.0.1/mkdocs.yml +8 -0
- monkay-0.0.1/monkay/__about__.py +4 -0
- monkay-0.0.1/monkay/__init__.py +13 -0
- monkay-0.0.1/monkay/base.py +354 -0
- monkay-0.0.1/pyproject.toml +105 -0
- monkay-0.0.1/tests/__init__.py +3 -0
- monkay-0.0.1/tests/targets/extension.py +35 -0
- monkay-0.0.1/tests/targets/fn_module.py +6 -0
- monkay-0.0.1/tests/targets/module_full.py +34 -0
- monkay-0.0.1/tests/targets/module_full_preloaded1.py +2 -0
- monkay-0.0.1/tests/targets/module_full_preloaded1_fn.py +0 -0
- monkay-0.0.1/tests/targets/module_preloaded1.py +0 -0
- monkay-0.0.1/tests/targets/settings.py +20 -0
- monkay-0.0.1/tests/test_basic.py +152 -0
|
@@ -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"
|
monkay-0.0.1/.gitignore
ADDED
|
@@ -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
|
monkay-0.0.1/LICENSE.txt
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/monkay)
|
|
26
|
+
[](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
|
+
[](https://pypi.org/project/monkay)
|
|
4
|
+
[](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,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.
|
monkay-0.0.1/mkdocs.yml
ADDED
|
@@ -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,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,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
|
+
)
|
|
File without changes
|
|
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 == ""
|