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.
Files changed (109) hide show
  1. isik-0.1.0/.gitattributes +1 -0
  2. isik-0.1.0/.github/workflows/publish.yml +47 -0
  3. isik-0.1.0/.github/workflows/tests.yml +58 -0
  4. isik-0.1.0/.gitignore +18 -0
  5. isik-0.1.0/.pre-commit-config.yaml +15 -0
  6. isik-0.1.0/LICENCE +21 -0
  7. isik-0.1.0/PKG-INFO +40 -0
  8. isik-0.1.0/README.md +1 -0
  9. isik-0.1.0/docs/INDEX.md +1 -0
  10. isik-0.1.0/isik/__init__.py +1 -0
  11. isik-0.1.0/isik/_internal/__init__.py +11 -0
  12. isik-0.1.0/isik/common/__init__.py +0 -0
  13. isik-0.1.0/isik/common/config/__init__.py +25 -0
  14. isik-0.1.0/isik/common/config/builder.py +105 -0
  15. isik-0.1.0/isik/common/config/casters.py +56 -0
  16. isik-0.1.0/isik/common/config/exceptions.py +1 -0
  17. isik-0.1.0/isik/common/utils/__init__.py +46 -0
  18. isik-0.1.0/isik/common/utils/caching.py +21 -0
  19. isik-0.1.0/isik/common/utils/concurrency.py +83 -0
  20. isik-0.1.0/isik/common/utils/error_handling.py +155 -0
  21. isik-0.1.0/isik/common/utils/functional.py +190 -0
  22. isik-0.1.0/isik/common/utils/iterables.py +41 -0
  23. isik-0.1.0/isik/common/utils/metaclasses.py +70 -0
  24. isik-0.1.0/isik/common/utils/sentinel.py +38 -0
  25. isik-0.1.0/isik/common/utils/strings.py +23 -0
  26. isik-0.1.0/isik/django/__init__.py +3 -0
  27. isik-0.1.0/isik/django/apps/__init__.py +0 -0
  28. isik-0.1.0/isik/django/apps/common/__init__.py +0 -0
  29. isik-0.1.0/isik/django/apps/common/admin.py +119 -0
  30. isik-0.1.0/isik/django/apps/common/apps.py +15 -0
  31. isik-0.1.0/isik/django/apps/common/backends/__init__.py +0 -0
  32. isik-0.1.0/isik/django/apps/common/backends/auth.py +20 -0
  33. isik-0.1.0/isik/django/apps/common/db.py +13 -0
  34. isik-0.1.0/isik/django/apps/common/email.py +10 -0
  35. isik-0.1.0/isik/django/apps/common/fields/__init__.py +0 -0
  36. isik-0.1.0/isik/django/apps/common/fields/gfk.py +101 -0
  37. isik-0.1.0/isik/django/apps/common/lookups.py +6 -0
  38. isik-0.1.0/isik/django/apps/common/middleware/__init__.py +0 -0
  39. isik-0.1.0/isik/django/apps/common/middleware/media_white_noise.py +42 -0
  40. isik-0.1.0/isik/django/apps/common/middleware/session.py +26 -0
  41. isik-0.1.0/isik/django/apps/common/models.py +89 -0
  42. isik-0.1.0/isik/django/apps/common/orm.py +22 -0
  43. isik-0.1.0/isik/django/apps/common/skippable_validators.py +72 -0
  44. isik-0.1.0/isik/django/drf/__init__.py +3 -0
  45. isik-0.1.0/isik/django/drf/error_handling.py +17 -0
  46. isik-0.1.0/isik/django/drf/filters.py +12 -0
  47. isik-0.1.0/isik/django/drf/pagination.py +40 -0
  48. isik-0.1.0/isik/django/drf/permissions.py +108 -0
  49. isik-0.1.0/isik/django/http_exceptions/__init__.py +0 -0
  50. isik-0.1.0/isik/django/http_exceptions/decorators.py +29 -0
  51. isik-0.1.0/isik/django/http_exceptions/exceptions.py +107 -0
  52. isik-0.1.0/isik/django/http_exceptions/middleware.py +55 -0
  53. isik-0.1.0/isik/sentry/__init__.py +3 -0
  54. isik-0.1.0/isik/sentry/utils/__init__.py +9 -0
  55. isik-0.1.0/pyproject.toml +74 -0
  56. isik-0.1.0/tests/__init__.py +0 -0
  57. isik-0.1.0/tests/common/__init__.py +0 -0
  58. isik-0.1.0/tests/common/config/__init__.py +0 -0
  59. isik-0.1.0/tests/common/config/test_builder.py +210 -0
  60. isik-0.1.0/tests/common/config/test_casters.py +76 -0
  61. isik-0.1.0/tests/common/utils/__init__.py +0 -0
  62. isik-0.1.0/tests/common/utils/test_caching.py +30 -0
  63. isik-0.1.0/tests/common/utils/test_concurrency.py +92 -0
  64. isik-0.1.0/tests/common/utils/test_error_handling.py +156 -0
  65. isik-0.1.0/tests/common/utils/test_functional.py +141 -0
  66. isik-0.1.0/tests/common/utils/test_iterables.py +48 -0
  67. isik-0.1.0/tests/common/utils/test_metaclasses.py +103 -0
  68. isik-0.1.0/tests/common/utils/test_sentinel.py +24 -0
  69. isik-0.1.0/tests/common/utils/test_strings.py +67 -0
  70. isik-0.1.0/tests/conftest.py +12 -0
  71. isik-0.1.0/tests/django/__init__.py +0 -0
  72. isik-0.1.0/tests/django/apps/__init__.py +0 -0
  73. isik-0.1.0/tests/django/apps/common/__init__.py +0 -0
  74. isik-0.1.0/tests/django/apps/common/backends/__init__.py +0 -0
  75. isik-0.1.0/tests/django/apps/common/backends/test_auth.py +50 -0
  76. isik-0.1.0/tests/django/apps/common/fields/__init__.py +0 -0
  77. isik-0.1.0/tests/django/apps/common/fields/test_gfk.py +53 -0
  78. isik-0.1.0/tests/django/apps/common/middleware/__init__.py +0 -0
  79. isik-0.1.0/tests/django/apps/common/middleware/test_media_white_noise.py +57 -0
  80. isik-0.1.0/tests/django/apps/common/middleware/test_session.py +54 -0
  81. isik-0.1.0/tests/django/apps/common/test_admin.py +163 -0
  82. isik-0.1.0/tests/django/apps/common/test_db.py +27 -0
  83. isik-0.1.0/tests/django/apps/common/test_email.py +12 -0
  84. isik-0.1.0/tests/django/apps/common/test_lookups.py +18 -0
  85. isik-0.1.0/tests/django/apps/common/test_models.py +99 -0
  86. isik-0.1.0/tests/django/apps/common/test_orm.py +33 -0
  87. isik-0.1.0/tests/django/apps/common/test_skippable_validators.py +71 -0
  88. isik-0.1.0/tests/django/drf/__init__.py +0 -0
  89. isik-0.1.0/tests/django/drf/test_error_handling.py +61 -0
  90. isik-0.1.0/tests/django/drf/test_filters.py +27 -0
  91. isik-0.1.0/tests/django/drf/test_pagination.py +45 -0
  92. isik-0.1.0/tests/django/drf/test_permissions.py +212 -0
  93. isik-0.1.0/tests/django/http_exceptions/__init__.py +0 -0
  94. isik-0.1.0/tests/django/http_exceptions/test_decorators.py +37 -0
  95. isik-0.1.0/tests/django/http_exceptions/test_exceptions.py +142 -0
  96. isik-0.1.0/tests/django/http_exceptions/test_middleware.py +104 -0
  97. isik-0.1.0/tests/django_settings.py +78 -0
  98. isik-0.1.0/tests/media/hello.txt +1 -0
  99. isik-0.1.0/tests/sentry/__init__.py +0 -0
  100. isik-0.1.0/tests/sentry/test_utils.py +43 -0
  101. isik-0.1.0/tests/templates/welcome.mjml +9 -0
  102. isik-0.1.0/tests/templates/welcome.txt +1 -0
  103. isik-0.1.0/tests/test_internal.py +18 -0
  104. isik-0.1.0/tests/testapp/__init__.py +0 -0
  105. isik-0.1.0/tests/testapp/apps.py +7 -0
  106. isik-0.1.0/tests/testapp/migrations/0001_initial.py +144 -0
  107. isik-0.1.0/tests/testapp/migrations/__init__.py +0 -0
  108. isik-0.1.0/tests/testapp/models.py +117 -0
  109. 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,18 @@
1
+ # Python
2
+ __pycache__
3
+ *.pyc
4
+ .pytest_cache
5
+ .ruff_cache
6
+ .coverage
7
+ htmlcov
8
+ .hypothesis
9
+
10
+ # IDEs & Editors
11
+ .idea
12
+
13
+ # Frameworks & Libraries
14
+ dist
15
+ poetry.lock
16
+
17
+ # Project Specific
18
+ # ETC
@@ -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
@@ -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)