isik 0.1.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.
- isik-0.1.0/.gitattributes +1 -0
- isik-0.1.0/.github/workflows/publish.yml +47 -0
- isik-0.1.0/.github/workflows/tests.yml +58 -0
- isik-0.1.0/.gitignore +18 -0
- isik-0.1.0/.pre-commit-config.yaml +15 -0
- isik-0.1.0/LICENCE +21 -0
- isik-0.1.0/PKG-INFO +40 -0
- isik-0.1.0/README.md +1 -0
- isik-0.1.0/docs/INDEX.md +1 -0
- isik-0.1.0/isik/__init__.py +1 -0
- isik-0.1.0/isik/_internal/__init__.py +11 -0
- isik-0.1.0/isik/common/__init__.py +0 -0
- isik-0.1.0/isik/common/config/__init__.py +25 -0
- isik-0.1.0/isik/common/config/builder.py +105 -0
- isik-0.1.0/isik/common/config/casters.py +56 -0
- isik-0.1.0/isik/common/config/exceptions.py +1 -0
- isik-0.1.0/isik/common/utils/__init__.py +46 -0
- isik-0.1.0/isik/common/utils/caching.py +21 -0
- isik-0.1.0/isik/common/utils/concurrency.py +83 -0
- isik-0.1.0/isik/common/utils/error_handling.py +155 -0
- isik-0.1.0/isik/common/utils/functional.py +190 -0
- isik-0.1.0/isik/common/utils/iterables.py +41 -0
- isik-0.1.0/isik/common/utils/metaclasses.py +70 -0
- isik-0.1.0/isik/common/utils/sentinel.py +38 -0
- isik-0.1.0/isik/common/utils/strings.py +23 -0
- isik-0.1.0/isik/django/__init__.py +3 -0
- isik-0.1.0/isik/django/apps/__init__.py +0 -0
- isik-0.1.0/isik/django/apps/common/__init__.py +0 -0
- isik-0.1.0/isik/django/apps/common/admin.py +119 -0
- isik-0.1.0/isik/django/apps/common/apps.py +15 -0
- isik-0.1.0/isik/django/apps/common/backends/__init__.py +0 -0
- isik-0.1.0/isik/django/apps/common/backends/auth.py +20 -0
- isik-0.1.0/isik/django/apps/common/db.py +13 -0
- isik-0.1.0/isik/django/apps/common/email.py +10 -0
- isik-0.1.0/isik/django/apps/common/fields/__init__.py +0 -0
- isik-0.1.0/isik/django/apps/common/fields/gfk.py +101 -0
- isik-0.1.0/isik/django/apps/common/lookups.py +6 -0
- isik-0.1.0/isik/django/apps/common/middleware/__init__.py +0 -0
- isik-0.1.0/isik/django/apps/common/middleware/media_white_noise.py +42 -0
- isik-0.1.0/isik/django/apps/common/middleware/session.py +26 -0
- isik-0.1.0/isik/django/apps/common/models.py +89 -0
- isik-0.1.0/isik/django/apps/common/orm.py +22 -0
- isik-0.1.0/isik/django/apps/common/skippable_validators.py +72 -0
- isik-0.1.0/isik/django/drf/__init__.py +3 -0
- isik-0.1.0/isik/django/drf/error_handling.py +17 -0
- isik-0.1.0/isik/django/drf/filters.py +12 -0
- isik-0.1.0/isik/django/drf/pagination.py +40 -0
- isik-0.1.0/isik/django/drf/permissions.py +108 -0
- isik-0.1.0/isik/django/http_exceptions/__init__.py +0 -0
- isik-0.1.0/isik/django/http_exceptions/decorators.py +29 -0
- isik-0.1.0/isik/django/http_exceptions/exceptions.py +107 -0
- isik-0.1.0/isik/django/http_exceptions/middleware.py +55 -0
- isik-0.1.0/isik/sentry/__init__.py +3 -0
- isik-0.1.0/isik/sentry/utils/__init__.py +9 -0
- isik-0.1.0/pyproject.toml +74 -0
- isik-0.1.0/tests/__init__.py +0 -0
- isik-0.1.0/tests/common/__init__.py +0 -0
- isik-0.1.0/tests/common/config/__init__.py +0 -0
- isik-0.1.0/tests/common/config/test_builder.py +210 -0
- isik-0.1.0/tests/common/config/test_casters.py +76 -0
- isik-0.1.0/tests/common/utils/__init__.py +0 -0
- isik-0.1.0/tests/common/utils/test_caching.py +30 -0
- isik-0.1.0/tests/common/utils/test_concurrency.py +92 -0
- isik-0.1.0/tests/common/utils/test_error_handling.py +156 -0
- isik-0.1.0/tests/common/utils/test_functional.py +141 -0
- isik-0.1.0/tests/common/utils/test_iterables.py +48 -0
- isik-0.1.0/tests/common/utils/test_metaclasses.py +103 -0
- isik-0.1.0/tests/common/utils/test_sentinel.py +24 -0
- isik-0.1.0/tests/common/utils/test_strings.py +67 -0
- isik-0.1.0/tests/conftest.py +12 -0
- isik-0.1.0/tests/django/__init__.py +0 -0
- isik-0.1.0/tests/django/apps/__init__.py +0 -0
- isik-0.1.0/tests/django/apps/common/__init__.py +0 -0
- isik-0.1.0/tests/django/apps/common/backends/__init__.py +0 -0
- isik-0.1.0/tests/django/apps/common/backends/test_auth.py +50 -0
- isik-0.1.0/tests/django/apps/common/fields/__init__.py +0 -0
- isik-0.1.0/tests/django/apps/common/fields/test_gfk.py +53 -0
- isik-0.1.0/tests/django/apps/common/middleware/__init__.py +0 -0
- isik-0.1.0/tests/django/apps/common/middleware/test_media_white_noise.py +57 -0
- isik-0.1.0/tests/django/apps/common/middleware/test_session.py +54 -0
- isik-0.1.0/tests/django/apps/common/test_admin.py +163 -0
- isik-0.1.0/tests/django/apps/common/test_db.py +27 -0
- isik-0.1.0/tests/django/apps/common/test_email.py +12 -0
- isik-0.1.0/tests/django/apps/common/test_lookups.py +18 -0
- isik-0.1.0/tests/django/apps/common/test_models.py +99 -0
- isik-0.1.0/tests/django/apps/common/test_orm.py +33 -0
- isik-0.1.0/tests/django/apps/common/test_skippable_validators.py +71 -0
- isik-0.1.0/tests/django/drf/__init__.py +0 -0
- isik-0.1.0/tests/django/drf/test_error_handling.py +61 -0
- isik-0.1.0/tests/django/drf/test_filters.py +27 -0
- isik-0.1.0/tests/django/drf/test_pagination.py +45 -0
- isik-0.1.0/tests/django/drf/test_permissions.py +212 -0
- isik-0.1.0/tests/django/http_exceptions/__init__.py +0 -0
- isik-0.1.0/tests/django/http_exceptions/test_decorators.py +37 -0
- isik-0.1.0/tests/django/http_exceptions/test_exceptions.py +142 -0
- isik-0.1.0/tests/django/http_exceptions/test_middleware.py +104 -0
- isik-0.1.0/tests/django_settings.py +78 -0
- isik-0.1.0/tests/media/hello.txt +1 -0
- isik-0.1.0/tests/sentry/__init__.py +0 -0
- isik-0.1.0/tests/sentry/test_utils.py +43 -0
- isik-0.1.0/tests/templates/welcome.mjml +9 -0
- isik-0.1.0/tests/templates/welcome.txt +1 -0
- isik-0.1.0/tests/test_internal.py +18 -0
- isik-0.1.0/tests/testapp/__init__.py +0 -0
- isik-0.1.0/tests/testapp/apps.py +7 -0
- isik-0.1.0/tests/testapp/migrations/0001_initial.py +144 -0
- isik-0.1.0/tests/testapp/migrations/__init__.py +0 -0
- isik-0.1.0/tests/testapp/models.py +117 -0
- isik-0.1.0/uv.lock +748 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v3
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v4
|
|
19
|
+
with:
|
|
20
|
+
python-version: '3.12'
|
|
21
|
+
|
|
22
|
+
- name: Install build tools
|
|
23
|
+
run: |
|
|
24
|
+
python -m pip install --upgrade pip setuptools wheel build
|
|
25
|
+
|
|
26
|
+
- name: Check if version changed
|
|
27
|
+
id: check_version
|
|
28
|
+
run: |
|
|
29
|
+
LOCAL_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
30
|
+
PUBLISHED_VERSION=$(curl -s -f https://pypi.org/pypi/isik/json | python -c "import json, sys; print(json.load(sys.stdin).get('info', {}).get('version', ''))" || echo "")
|
|
31
|
+
echo "Local version: $LOCAL_VERSION, published version: $PUBLISHED_VERSION"
|
|
32
|
+
if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then
|
|
33
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
34
|
+
else
|
|
35
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
- name: Build package
|
|
39
|
+
if: steps.check_version.outputs.should_publish == 'true'
|
|
40
|
+
run: |
|
|
41
|
+
python -m build
|
|
42
|
+
|
|
43
|
+
- name: Publish to PyPI
|
|
44
|
+
if: steps.check_version.outputs.should_publish == 'true'
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
with:
|
|
47
|
+
packages-dir: dist
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- master
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ['3.11', '3.12', '3.13', '3.14']
|
|
17
|
+
os-version: [ubuntu-22.04]
|
|
18
|
+
|
|
19
|
+
runs-on: ${{ matrix.os-version }}
|
|
20
|
+
|
|
21
|
+
services:
|
|
22
|
+
postgres:
|
|
23
|
+
image: postgres:16
|
|
24
|
+
env:
|
|
25
|
+
POSTGRES_USER: postgres
|
|
26
|
+
POSTGRES_PASSWORD: postgres
|
|
27
|
+
POSTGRES_DB: isik_test
|
|
28
|
+
ports:
|
|
29
|
+
- 5432:5432
|
|
30
|
+
options: >-
|
|
31
|
+
--health-cmd pg_isready
|
|
32
|
+
--health-interval 10s
|
|
33
|
+
--health-timeout 5s
|
|
34
|
+
--health-retries 5
|
|
35
|
+
|
|
36
|
+
env:
|
|
37
|
+
POSTGRES_HOST: localhost
|
|
38
|
+
POSTGRES_PORT: 5432
|
|
39
|
+
POSTGRES_USER: postgres
|
|
40
|
+
POSTGRES_PASSWORD: postgres
|
|
41
|
+
POSTGRES_DB: isik_test
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v3
|
|
45
|
+
|
|
46
|
+
- name: Install uv
|
|
47
|
+
uses: astral-sh/setup-uv@v3
|
|
48
|
+
with:
|
|
49
|
+
python-version: ${{ matrix.python-version }}
|
|
50
|
+
|
|
51
|
+
- name: Install dependencies
|
|
52
|
+
run: |
|
|
53
|
+
uv sync --all-extras
|
|
54
|
+
|
|
55
|
+
- name: Run tests with coverage
|
|
56
|
+
run: |
|
|
57
|
+
uv run coverage run -m pytest
|
|
58
|
+
uv run coverage report -m
|
isik-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: end-of-file-fixer
|
|
6
|
+
- id: trailing-whitespace
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-added-large-files
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
11
|
+
rev: v0.9.0
|
|
12
|
+
hooks:
|
|
13
|
+
- id: ruff
|
|
14
|
+
args: [ --fix ]
|
|
15
|
+
- id: ruff-format
|
isik-0.1.0/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Işık Kaplan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
isik-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: isik
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Everyday python utilities.
|
|
5
|
+
Project-URL: Homepage, https://github.com/isik-kaplan/isik
|
|
6
|
+
Project-URL: Repository, https://github.com/isik-kaplan/isik
|
|
7
|
+
Project-URL: Documentation, https://github.com/isik-kaplan/isik
|
|
8
|
+
Author-email: Işık Kaplan <isik.kaplan@outlook.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENCE
|
|
11
|
+
Keywords: utility
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Provides-Extra: all
|
|
17
|
+
Requires-Dist: dalf; extra == 'all'
|
|
18
|
+
Requires-Dist: django; extra == 'all'
|
|
19
|
+
Requires-Dist: django-lifecycle; extra == 'all'
|
|
20
|
+
Requires-Dist: django-object-actions; extra == 'all'
|
|
21
|
+
Requires-Dist: django-pghistory; extra == 'all'
|
|
22
|
+
Requires-Dist: djangorestframework; extra == 'all'
|
|
23
|
+
Requires-Dist: mjml-python; extra == 'all'
|
|
24
|
+
Requires-Dist: sentry-sdk; extra == 'all'
|
|
25
|
+
Requires-Dist: whitenoise; extra == 'all'
|
|
26
|
+
Provides-Extra: django
|
|
27
|
+
Requires-Dist: dalf; extra == 'django'
|
|
28
|
+
Requires-Dist: django; extra == 'django'
|
|
29
|
+
Requires-Dist: django-lifecycle; extra == 'django'
|
|
30
|
+
Requires-Dist: django-object-actions; extra == 'django'
|
|
31
|
+
Requires-Dist: django-pghistory; extra == 'django'
|
|
32
|
+
Requires-Dist: mjml-python; extra == 'django'
|
|
33
|
+
Requires-Dist: whitenoise; extra == 'django'
|
|
34
|
+
Provides-Extra: drf
|
|
35
|
+
Requires-Dist: djangorestframework; extra == 'drf'
|
|
36
|
+
Provides-Extra: sentry
|
|
37
|
+
Requires-Dist: sentry-sdk; extra == 'sentry'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
Stuff I use daily
|
isik-0.1.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
docs/INDEX.md
|
isik-0.1.0/docs/INDEX.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Stuff I use daily
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def check_extra(extra_name, package_name):
|
|
5
|
+
try:
|
|
6
|
+
importlib.import_module(package_name)
|
|
7
|
+
except ImportError as exception:
|
|
8
|
+
raise ImportError(
|
|
9
|
+
f"The module you are trying to use requires '{extra_name}'. "
|
|
10
|
+
f"Please install it with: pip install isik[{extra_name}]"
|
|
11
|
+
) from exception
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from isik.common.config.builder import Config, config
|
|
2
|
+
from isik.common.config.casters import (
|
|
3
|
+
boolean,
|
|
4
|
+
caster,
|
|
5
|
+
comma_separated_float_list,
|
|
6
|
+
comma_separated_int_list,
|
|
7
|
+
comma_separated_list,
|
|
8
|
+
integer,
|
|
9
|
+
string,
|
|
10
|
+
)
|
|
11
|
+
from isik.common.config.exceptions import ConfigError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Config",
|
|
16
|
+
"ConfigError",
|
|
17
|
+
"boolean",
|
|
18
|
+
"caster",
|
|
19
|
+
"comma_separated_float_list",
|
|
20
|
+
"comma_separated_int_list",
|
|
21
|
+
"comma_separated_list",
|
|
22
|
+
"config",
|
|
23
|
+
"integer",
|
|
24
|
+
"string",
|
|
25
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from isik.common.config.exceptions import ConfigError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_MISSING = object()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Config(dict):
|
|
10
|
+
"""
|
|
11
|
+
A dict that also allows attribute access, e.g. config.DATABASE.HOST, and can reload its
|
|
12
|
+
values from the environment via refresh() - callable on the root config or on any nested
|
|
13
|
+
one, refreshing just that subtree in place so existing references to it see the update too.
|
|
14
|
+
|
|
15
|
+
A schema key that collides with a dict method name (items, keys, ...) or with refresh
|
|
16
|
+
itself is only reachable via item access (config["refresh"]), not attribute access.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__setattr__ = dict.__setitem__
|
|
20
|
+
|
|
21
|
+
def __getattr__(self, name):
|
|
22
|
+
try:
|
|
23
|
+
return self[name]
|
|
24
|
+
except KeyError as exc:
|
|
25
|
+
raise AttributeError(name) from exc
|
|
26
|
+
|
|
27
|
+
def refresh(self, *path):
|
|
28
|
+
"""
|
|
29
|
+
Re-read values from the environment, in place. With no arguments, refreshes every
|
|
30
|
+
value under this node. With a path of key names, refreshes only that one nested
|
|
31
|
+
value instead - e.g. config.refresh("DATABASE", "HOST") or, equivalently,
|
|
32
|
+
config.DATABASE.refresh("HOST").
|
|
33
|
+
"""
|
|
34
|
+
if not path:
|
|
35
|
+
for key in self._schema:
|
|
36
|
+
self._refresh_key(key)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
key, *rest = path
|
|
40
|
+
if rest:
|
|
41
|
+
self[key].refresh(*rest)
|
|
42
|
+
else:
|
|
43
|
+
self._refresh_key(key)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def _refresh_key(self, key):
|
|
47
|
+
try:
|
|
48
|
+
value = self._schema[key]
|
|
49
|
+
except KeyError:
|
|
50
|
+
raise ConfigError(f"'{key}' is not a key in this config.") from None
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
self[key].refresh()
|
|
53
|
+
else:
|
|
54
|
+
self[key] = _read_leaf(value, _environment_key(self._prefix, [*self._path, key], self._sep))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _environment_key(prefix, path, sep):
|
|
58
|
+
return sep.join([*([prefix] if prefix else []), *path])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_leaf(caster, environment_key):
|
|
62
|
+
try:
|
|
63
|
+
raw_value = os.environ[environment_key]
|
|
64
|
+
except KeyError:
|
|
65
|
+
default = getattr(caster, "missing_default", _MISSING)
|
|
66
|
+
if default is _MISSING:
|
|
67
|
+
raise ConfigError(
|
|
68
|
+
f"Environment variable {environment_key} not found."
|
|
69
|
+
f" Please set it or provide a `missing_default` to your caster."
|
|
70
|
+
) from None
|
|
71
|
+
return default
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return caster(raw_value)
|
|
75
|
+
except Exception as exception:
|
|
76
|
+
default = getattr(caster, "error_default", _MISSING)
|
|
77
|
+
if default is _MISSING:
|
|
78
|
+
raise ConfigError(
|
|
79
|
+
f"Error while parsing {environment_key}={raw_value!r} with '{caster}'."
|
|
80
|
+
" Please check the value and the caster or provide an `error_default` to your caster."
|
|
81
|
+
) from exception
|
|
82
|
+
return default
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build(data, path, prefix, sep):
|
|
86
|
+
result = Config()
|
|
87
|
+
object.__setattr__(result, "_schema", data)
|
|
88
|
+
object.__setattr__(result, "_path", path)
|
|
89
|
+
object.__setattr__(result, "_prefix", prefix)
|
|
90
|
+
object.__setattr__(result, "_sep", sep)
|
|
91
|
+
for key, value in data.items():
|
|
92
|
+
key_path = [*path, key]
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
result[key] = _build(value, key_path, prefix, sep)
|
|
95
|
+
elif callable(value):
|
|
96
|
+
result[key] = _read_leaf(value, _environment_key(prefix, key_path, sep))
|
|
97
|
+
else:
|
|
98
|
+
raise ConfigError(
|
|
99
|
+
f"Values either must be callables or other mappings, not {type(value)}. Key={'.'.join(key_path)}."
|
|
100
|
+
)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def config(data, *, prefix=None, sep="__"):
|
|
105
|
+
return _build(data, [], prefix, sep)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def caster(f):
|
|
5
|
+
"""
|
|
6
|
+
Turns a plain string->value function into a config caster that accepts an optional
|
|
7
|
+
missing_default (used when the environment variable isn't set) and error_default (used
|
|
8
|
+
when f(value) raises). Neither is set by default, meaning a missing or unparseable
|
|
9
|
+
value raises ConfigError instead.
|
|
10
|
+
"""
|
|
11
|
+
sentinel = object()
|
|
12
|
+
|
|
13
|
+
def wrapper(missing_default=sentinel, error_default=sentinel):
|
|
14
|
+
@wraps(f)
|
|
15
|
+
def function_clone(value):
|
|
16
|
+
return f(value)
|
|
17
|
+
|
|
18
|
+
if missing_default is not sentinel:
|
|
19
|
+
function_clone.missing_default = missing_default
|
|
20
|
+
if error_default is not sentinel:
|
|
21
|
+
function_clone.error_default = error_default
|
|
22
|
+
return function_clone
|
|
23
|
+
|
|
24
|
+
return wrapper
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@caster
|
|
28
|
+
def comma_separated_list(value):
|
|
29
|
+
return value.split(",")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@caster
|
|
33
|
+
def comma_separated_int_list(value):
|
|
34
|
+
return [int(i) for i in value.split(",")]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@caster
|
|
38
|
+
def comma_separated_float_list(value):
|
|
39
|
+
return [float(i) for i in value.split(",")]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@caster
|
|
43
|
+
def boolean(value):
|
|
44
|
+
truthy = ["true", "True", "1"]
|
|
45
|
+
falsy = ["false", "False", "0"]
|
|
46
|
+
|
|
47
|
+
if value in truthy:
|
|
48
|
+
return True
|
|
49
|
+
elif value in falsy:
|
|
50
|
+
return False
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f"Value {value!r} can not be parsed into a boolean.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
string = caster(str)
|
|
56
|
+
integer = caster(int)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class ConfigError(ValueError): ...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from isik.common.utils.caching import get_cached
|
|
2
|
+
from isik.common.utils.concurrency import ContextLocal, ThreadLocal, ThreadLock
|
|
3
|
+
from isik.common.utils.error_handling import SuppressAndRun, TransformExceptions, suppress_callable
|
|
4
|
+
from isik.common.utils.functional import (
|
|
5
|
+
cloned,
|
|
6
|
+
enabled_if,
|
|
7
|
+
identity,
|
|
8
|
+
noop,
|
|
9
|
+
raises,
|
|
10
|
+
require_exclusive_keys,
|
|
11
|
+
returns,
|
|
12
|
+
with_attrs,
|
|
13
|
+
)
|
|
14
|
+
from isik.common.utils.iterables import all_combinations, first_of, not_none, purge_iterable, purge_mapping
|
|
15
|
+
from isik.common.utils.metaclasses import transform
|
|
16
|
+
from isik.common.utils.sentinel import Sentinel
|
|
17
|
+
from isik.common.utils.strings import camel_to_snake, snake_to_human, snake_to_pascal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ContextLocal",
|
|
22
|
+
"Sentinel",
|
|
23
|
+
"SuppressAndRun",
|
|
24
|
+
"ThreadLocal",
|
|
25
|
+
"ThreadLock",
|
|
26
|
+
"TransformExceptions",
|
|
27
|
+
"all_combinations",
|
|
28
|
+
"camel_to_snake",
|
|
29
|
+
"cloned",
|
|
30
|
+
"enabled_if",
|
|
31
|
+
"first_of",
|
|
32
|
+
"get_cached",
|
|
33
|
+
"identity",
|
|
34
|
+
"noop",
|
|
35
|
+
"not_none",
|
|
36
|
+
"purge_iterable",
|
|
37
|
+
"purge_mapping",
|
|
38
|
+
"raises",
|
|
39
|
+
"require_exclusive_keys",
|
|
40
|
+
"returns",
|
|
41
|
+
"snake_to_human",
|
|
42
|
+
"snake_to_pascal",
|
|
43
|
+
"suppress_callable",
|
|
44
|
+
"transform",
|
|
45
|
+
"with_attrs",
|
|
46
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
def get_cached(obj, attr, factory):
|
|
2
|
+
"""
|
|
3
|
+
Get a cached attribute from an object, computing and storing it if absent.
|
|
4
|
+
|
|
5
|
+
Bypasses __getattribute__ via object.__getattribute__ and object.__setattr__,
|
|
6
|
+
making it safe to call from within a custom __getattribute__ implementation.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
obj: The object to cache the attribute on.
|
|
10
|
+
attr: The attribute name to use as the cache key.
|
|
11
|
+
factory: A zero-argument callable that computes the value on cache miss.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
The cached value if present, otherwise the result of calling factory().
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
return object.__getattribute__(obj, attr)
|
|
18
|
+
except AttributeError:
|
|
19
|
+
value = factory()
|
|
20
|
+
object.__setattr__(obj, attr, value)
|
|
21
|
+
return value
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ThreadLocal:
|
|
6
|
+
"""
|
|
7
|
+
A factory for thread local identified by name.
|
|
8
|
+
Calling ThreadLocal("FOO") returns a thread local object shared across all calls with the same name.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_registry = {}
|
|
12
|
+
_registry_lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
def __new__(cls, name):
|
|
15
|
+
if name not in cls._registry:
|
|
16
|
+
with cls._registry_lock:
|
|
17
|
+
if name not in cls._registry:
|
|
18
|
+
cls._registry[name] = threading.local()
|
|
19
|
+
return cls._registry[name]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ThreadLock:
|
|
23
|
+
"""
|
|
24
|
+
A factory for thread lock identified by name.
|
|
25
|
+
Calling ThreadLock("FOO") returns a lock object shared across all calls with the same name.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_registry = {}
|
|
29
|
+
_registry_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
def __new__(cls, name):
|
|
32
|
+
if name not in cls._registry:
|
|
33
|
+
with cls._registry_lock:
|
|
34
|
+
if name not in cls._registry:
|
|
35
|
+
cls._registry[name] = threading.Lock()
|
|
36
|
+
return cls._registry[name]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ContextLocal:
|
|
40
|
+
"""
|
|
41
|
+
A named, registry-backed namespace of ContextVars.
|
|
42
|
+
Calling ContextLocal("FOO") returns the same instance across all calls with the same name,
|
|
43
|
+
making it safe for async and coroutine contexts.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
local = ContextLocal("MY_NAMESPACE")
|
|
47
|
+
token = local.set("foo", "bar")
|
|
48
|
+
local.get("foo") # "bar"
|
|
49
|
+
local.get("foo", "default") # "bar"
|
|
50
|
+
local.reset("foo", token)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_registry = {}
|
|
54
|
+
_registry_lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
def __new__(cls, name):
|
|
57
|
+
if name not in cls._registry:
|
|
58
|
+
with cls._registry_lock:
|
|
59
|
+
if name not in cls._registry:
|
|
60
|
+
instance = super().__new__(cls)
|
|
61
|
+
object.__setattr__(instance, "_name", name)
|
|
62
|
+
object.__setattr__(instance, "_vars", {})
|
|
63
|
+
cls._registry[name] = instance
|
|
64
|
+
return cls._registry[name]
|
|
65
|
+
|
|
66
|
+
def _get_var(self, key):
|
|
67
|
+
vars_ = object.__getattribute__(self, "_vars")
|
|
68
|
+
if key not in vars_:
|
|
69
|
+
name = object.__getattribute__(self, "_name")
|
|
70
|
+
vars_[key] = ContextVar(f"{name}.{key}")
|
|
71
|
+
return vars_[key]
|
|
72
|
+
|
|
73
|
+
def get(self, key, *args):
|
|
74
|
+
"""Get a value. Accepts an optional default as a second argument, like dict.get()."""
|
|
75
|
+
return self._get_var(key).get(*args)
|
|
76
|
+
|
|
77
|
+
def set(self, key, value):
|
|
78
|
+
"""Set a value and return a token that can be used to restore the previous state."""
|
|
79
|
+
return self._get_var(key).set(value)
|
|
80
|
+
|
|
81
|
+
def reset(self, key, token):
|
|
82
|
+
"""Reset a value to its state before the corresponding set() call."""
|
|
83
|
+
self._get_var(key).reset(token)
|