cardo-python-utils 0.5.dev36__tar.gz → 0.5.dev38__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 (62) hide show
  1. {cardo_python_utils-0.5.dev36/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev38}/PKG-INFO +1 -1
  2. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38/cardo_python_utils.egg-info}/PKG-INFO +1 -1
  3. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/cardo_python_utils.egg-info/SOURCES.txt +1 -0
  4. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/pyproject.toml +1 -1
  5. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/README.md +11 -5
  6. cardo_python_utils-0.5.dev38/python_utils/django/celery/__init__.py +5 -0
  7. cardo_python_utils-0.5.dev38/python_utils/django/celery/tenant_aware_database_scheduler.py +207 -0
  8. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/tenant_context.py +8 -9
  9. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/tests/conftest.py +1 -1
  10. cardo_python_utils-0.5.dev36/python_utils/django/celery/__init__.py +0 -4
  11. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/LICENSE +0 -0
  12. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/MANIFEST.in +0 -0
  13. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/README.rst +0 -0
  14. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
  15. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/cardo_python_utils.egg-info/requires.txt +0 -0
  16. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/cardo_python_utils.egg-info/top_level.txt +0 -0
  17. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/__init__.py +0 -0
  18. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/choices.py +0 -0
  19. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/data_structures.py +0 -0
  20. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/db.py +0 -0
  21. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/__init__.py +0 -0
  22. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/admin/__init__.py +0 -0
  23. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/admin/auth.py +0 -0
  24. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/admin/templates/__init__.py +0 -0
  25. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/admin/templates/user_groups_changelist.html +0 -0
  26. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/admin/user_group.py +0 -0
  27. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/admin/views.py +0 -0
  28. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/api/__init__.py +0 -0
  29. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/api/drf.py +0 -0
  30. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/api/ninja.py +0 -0
  31. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/api/utils.py +0 -0
  32. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/apps.py +0 -0
  33. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/auth/service.py +0 -0
  34. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/celery/tenant_aware_task.py +0 -0
  35. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/db/__init__.py +0 -0
  36. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/db/routers.py +0 -0
  37. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/db/transaction.py +0 -0
  38. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/db/utils.py +0 -0
  39. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/management/__init__.py +0 -0
  40. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/management/commands/__init__.py +0 -0
  41. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/management/commands/migrateall.py +0 -0
  42. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/management/commands/tenant_aware_command.py +0 -0
  43. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/middleware/__init__.py +0 -0
  44. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/middleware/tenant_aware_http_middleware.py +0 -0
  45. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/models/__init__.py +0 -0
  46. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/models/user_group.py +0 -0
  47. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/oidc_settings.py +0 -0
  48. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/redis/__init__.py +0 -0
  49. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/redis/key_function.py +0 -0
  50. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/settings.py +0 -0
  51. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/storage/__init__.py +0 -0
  52. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/storage/tenant_aware_storage.py +0 -0
  53. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django/tests/__init__.py +0 -0
  54. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/django_utils.py +0 -0
  55. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/esma_choices.py +0 -0
  56. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/exceptions.py +0 -0
  57. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/imports.py +0 -0
  58. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/math.py +0 -0
  59. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/text.py +0 -0
  60. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/time.py +0 -0
  61. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/python_utils/types_hinting.py +0 -0
  62. {cardo_python_utils-0.5.dev36 → cardo_python_utils-0.5.dev38}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev36
3
+ Version: 0.5.dev38
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev36
3
+ Version: 0.5.dev38
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -37,6 +37,7 @@ python_utils/django/api/ninja.py
37
37
  python_utils/django/api/utils.py
38
38
  python_utils/django/auth/service.py
39
39
  python_utils/django/celery/__init__.py
40
+ python_utils/django/celery/tenant_aware_database_scheduler.py
40
41
  python_utils/django/celery/tenant_aware_task.py
41
42
  python_utils/django/db/__init__.py
42
43
  python_utils/django/db/routers.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cardo-python-utils"
7
- version = "0.5.dev36"
7
+ version = "0.5.dev38"
8
8
  description = "Python library enhanced with a wide range of functions for different scenarios."
9
9
  readme = "README.rst"
10
10
  requires-python = ">=3.8"
@@ -35,11 +35,17 @@ MIDDLEWARE = [
35
35
 
36
36
  # Include the database configuration for each tenant in the DATABASES setting.
37
37
  # You can use the get_database_configs() function from python_utils.django.db.utils as a helper.
38
- DATABASES = {
39
- 'tenant1': { ... },
40
- 'tenant2': { ... },
41
- ...
42
- }
38
+ from python_utils.django.db.utils import get_database_configs
39
+
40
+ for tenant, tenant_db_config in get_database_configs().items():
41
+ DATABASES[tenant] = {
42
+ "ENGINE": "django.db.backends.postgresql",
43
+ "NAME": tenant_db_config["name"],
44
+ "USER": tenant_db_config["user"],
45
+ "PASSWORD": tenant_db_config["password"],
46
+ "HOST": tenant_db_config["host"],
47
+ "PORT": tenant_db_config.get("port", 5432),
48
+ }
43
49
 
44
50
  # If you want to override the database alias to use for local development (when DEBUG is True).
45
51
  # By default, the first database defined in DATABASES is used.
@@ -0,0 +1,5 @@
1
+ from .tenant_aware_task import TenantAwareTask
2
+ from .tenant_aware_database_scheduler import TenantAwareDatabaseScheduler
3
+
4
+
5
+ __all__ = ["TenantAwareTask", "TenantAwareDatabaseScheduler"]
@@ -0,0 +1,207 @@
1
+ """
2
+ Multi-tenant Celery Beat scheduler.
3
+
4
+ Reads periodic tasks from every tenant database and injects the ``tenant``
5
+ kwarg so that ``TenantAwareTask`` can route each execution to the correct
6
+ database. Static beat-schedule entries (``CELERY_BEAT_SCHEDULE``) are also
7
+ run once per tenant.
8
+ """
9
+
10
+ from contextlib import contextmanager
11
+
12
+ from celery.utils.log import get_logger
13
+ from django.db import close_old_connections, transaction
14
+ from django.db.utils import DatabaseError, InterfaceError
15
+ from django_celery_beat.models import PeriodicTask, PeriodicTasks
16
+ from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry
17
+
18
+ from ..settings import TENANT_DATABASES, TENANT_KEY
19
+ from ..tenant_context import TenantContext
20
+
21
+ logger = get_logger(__name__)
22
+ debug, info, warning = logger.debug, logger.info, logger.warning
23
+
24
+
25
+ class TenantAwareModelEntry(ModelEntry):
26
+ """
27
+ A schedule entry that remembers which tenant it belongs to and
28
+ injects the tenant kwarg when the task is applied.
29
+ """
30
+
31
+ def __init__(self, model, app=None, tenant=None):
32
+ self.tenant = tenant
33
+ super().__init__(model, app=app)
34
+ # Prefix the name so entries from different tenants don't collide.
35
+ self.name = f"{self.tenant}::{self.name}"
36
+
37
+ @contextmanager
38
+ def _ensure_tenant_context(self):
39
+ """Set TenantContext only when not already active (TenantContext is not re-entrant)."""
40
+ if TenantContext.is_set():
41
+ yield
42
+ else:
43
+ with TenantContext(self.tenant):
44
+ yield
45
+
46
+ def _disable(self, model):
47
+ """Route the save to the correct tenant database."""
48
+ with self._ensure_tenant_context():
49
+ super()._disable(model)
50
+
51
+ def save(self):
52
+ """Persist last_run_at / total_run_count to the correct tenant database."""
53
+ with self._ensure_tenant_context():
54
+ super().save()
55
+
56
+ def __next__(self):
57
+ self.model.last_run_at = self._default_now()
58
+ self.model.total_run_count += 1
59
+ self.model.no_changes = True
60
+ return self.__class__(self.model, app=self.app, tenant=self.tenant)
61
+
62
+ next = __next__
63
+
64
+ def __repr__(self):
65
+ return f"<TenantAwareModelEntry: [{self.tenant}] {self.task} {self.schedule}>"
66
+
67
+
68
+ class TenantAwareDatabaseScheduler(DatabaseScheduler):
69
+ """
70
+ ``DatabaseScheduler`` subclass that aggregates periodic tasks across
71
+ **all** tenant databases.
72
+
73
+ For every tick it:
74
+
75
+ 1. Reads ``PeriodicTask`` rows from each tenant DB.
76
+ 2. Wraps them in ``TenantAwareModelEntry`` (which carries the tenant name).
77
+ 3. When a task is due, injects ``tenant=<name>`` into the task kwargs
78
+ so that ``TenantAwareTask`` can set the context on the worker side.
79
+ """
80
+
81
+ Entry = TenantAwareModelEntry
82
+
83
+ def __init__(self, *args, **kwargs):
84
+ self._last_timestamps: dict[str, object] = {}
85
+ super().__init__(*args, **kwargs)
86
+
87
+ @staticmethod
88
+ def _get_tenant_databases() -> set[str]:
89
+ return TENANT_DATABASES
90
+
91
+ def all_as_schedule(self):
92
+ """Build the combined schedule dict from all tenant databases."""
93
+ debug("TenantAwareDatabaseScheduler: Fetching schedule from all tenants")
94
+ combined: dict[str, TenantAwareModelEntry] = {}
95
+
96
+ for tenant in self._get_tenant_databases():
97
+ with TenantContext(tenant):
98
+ try:
99
+ for model in PeriodicTask.objects.enabled():
100
+ try:
101
+ entry = TenantAwareModelEntry(model, app=self.app, tenant=tenant)
102
+ combined[entry.name] = entry
103
+ except ValueError as exc:
104
+ logger.warning(
105
+ "TenantAwareDatabaseScheduler: skipping malformed periodic task '%s' in tenant '%s': %r",
106
+ model.name,
107
+ tenant,
108
+ exc,
109
+ )
110
+ except Exception as exc:
111
+ logger.exception(
112
+ "TenantAwareDatabaseScheduler: error reading tenant %s: %r",
113
+ tenant,
114
+ exc,
115
+ )
116
+
117
+ return combined
118
+
119
+ def schedule_changed(self):
120
+ """Check whether *any* tenant database has had a schedule change."""
121
+ changed = False
122
+ try:
123
+ close_old_connections()
124
+
125
+ for tenant in self._get_tenant_databases():
126
+ try:
127
+ transaction.commit(using=tenant)
128
+ except transaction.TransactionManagementError:
129
+ pass
130
+
131
+ try:
132
+ with TenantContext(tenant):
133
+ ts = PeriodicTasks.last_change()
134
+ last = self._last_timestamps.get(tenant)
135
+ if ts and ts > (last if last else ts):
136
+ changed = True
137
+ self._last_timestamps[tenant] = ts
138
+ except (DatabaseError, InterfaceError) as exc:
139
+ logger.warning(
140
+ "TenantAwareDatabaseScheduler: error checking schedule_changed for tenant %s: %r",
141
+ tenant,
142
+ exc,
143
+ )
144
+ except (DatabaseError, InterfaceError) as exc:
145
+ logger.exception("Database error in schedule_changed: %r", exc)
146
+ return False
147
+
148
+ return changed
149
+
150
+ def is_due(self, entry):
151
+ """Wrap in TenantContext so that any model.save() calls within
152
+ (e.g. disabling one-off tasks) are routed to the correct tenant DB."""
153
+ tenant = getattr(entry, "tenant", None)
154
+ if tenant:
155
+ with TenantContext(tenant):
156
+ return entry.is_due()
157
+ return entry.is_due()
158
+
159
+ def apply_entry(self, entry, producer=None):
160
+ """Inject the ``tenant`` kwarg before dispatching the task so that
161
+ ``TenantAwareTask.__call__`` can set the tenant context on the worker.
162
+
163
+ Built-in tasks like ``celery.backend_cleanup`` are skipped because
164
+ they are not ``TenantAwareTask`` subclasses and don't accept the kwarg.
165
+ """
166
+ tenant = getattr(entry, "tenant", None)
167
+ if tenant and entry.task != "celery.backend_cleanup":
168
+ extra_kwargs = dict(entry.kwargs or {})
169
+ extra_kwargs[TENANT_KEY] = tenant
170
+ entry.kwargs = extra_kwargs
171
+ super().apply_entry(entry, producer=producer)
172
+
173
+ def setup_schedule(self):
174
+ # Skip install_default_entries: it calls update_from_dict →
175
+ # Entry.from_entry which writes via _default_manager (no tenant
176
+ # routing). The celery.backend_cleanup task can be added to
177
+ # CELERY_BEAT_SCHEDULE in settings if needed.
178
+ self._update_static_schedule_per_tenant()
179
+
180
+ def _update_static_schedule_per_tenant(self):
181
+ """
182
+ For each entry in ``app.conf.beat_schedule``, create a
183
+ ``PeriodicTask`` row in every tenant database so that static
184
+ cron jobs run once per tenant.
185
+
186
+ Uses ``TenantContext`` so that all DB operations inside
187
+ ``_unpack_fields`` (schedule model lookups and saves) are
188
+ routed to the correct tenant database.
189
+ """
190
+ beat_schedule = self.app.conf.beat_schedule or {}
191
+ if not beat_schedule:
192
+ return
193
+
194
+ for tenant in self._get_tenant_databases():
195
+ with TenantContext(tenant):
196
+ for name, entry_fields in beat_schedule.items():
197
+ try:
198
+ defaults = ModelEntry._unpack_fields(**entry_fields)
199
+ PeriodicTask.objects.update_or_create(name=name, defaults=defaults)
200
+ except Exception as exc:
201
+ logger.exception(
202
+ "TenantAwareDatabaseScheduler: could not create "
203
+ "static schedule entry '%s' for tenant '%s': %r",
204
+ name,
205
+ tenant,
206
+ exc,
207
+ )
@@ -1,5 +1,4 @@
1
1
  import logging
2
- import sys
3
2
  from contextlib import ContextDecorator
4
3
  from contextvars import ContextVar
5
4
  from typing import Optional
@@ -9,7 +8,7 @@ from .settings import DATABASES
9
8
  logger = logging.getLogger(__name__)
10
9
 
11
10
  # ContextVar propagates automatically to async threads via sync_to_async
12
- _tenant_var: ContextVar[Optional[str]] = ContextVar('tenant', default=None)
11
+ _tenant_var: ContextVar[Optional[str]] = ContextVar("tenant", default=None)
13
12
 
14
13
 
15
14
  class TenantContext(ContextDecorator):
@@ -19,7 +18,7 @@ class TenantContext(ContextDecorator):
19
18
  1. As a context manager (sync and async):
20
19
  with TenantContext('tenant'):
21
20
  # do something
22
-
21
+
23
22
  async with TenantContext('tenant'):
24
23
  # do something async
25
24
 
@@ -77,16 +76,16 @@ class TenantContext(ContextDecorator):
77
76
  # The same applies to a single task, it is not allowed to set the
78
77
  # tenant in the same task to a different value.
79
78
  if TenantContext.get() != tenant:
80
- logger.error("ERROR: TENANT CONTEXT ALREADY SET")
81
- logger.error(f"Current tenant: {TenantContext.get()}, new tenant: {tenant}")
82
- return sys.exit(1)
79
+ raise RuntimeError(
80
+ f"Tenant context already set to '{TenantContext.get()}', "
81
+ f"cannot change to '{tenant}' within the same request/task lifecycle."
82
+ )
83
83
  else:
84
84
  # If the tenant is already set to the same value, we do nothing and return None.
85
85
  return None
86
86
 
87
87
  if tenant not in DATABASES:
88
- logger.error(f"Tenant '{tenant}' not found in DATABASES settings.")
89
- return sys.exit(1)
88
+ raise ValueError(f"Tenant '{tenant}' not found in DATABASES settings.")
90
89
 
91
90
  token = _tenant_var.set(tenant)
92
91
  logger.info(f"Tenant context set to {tenant}")
@@ -99,4 +98,4 @@ class TenantContext(ContextDecorator):
99
98
  _tenant_var.reset(token)
100
99
  else:
101
100
  _tenant_var.set(None)
102
- logger.info("Tenant context cleared")
101
+ logger.info("Tenant context cleared")
@@ -80,7 +80,7 @@ def mock_verify_scopes_ninja():
80
80
  Patches AuthBearer._verify_scopes, so that no real scope checking is done during tests.
81
81
  This is done because scope checking uses route resolution, which the test client does not support.
82
82
  """
83
- with patch("python_utils.keycloak.django.api.ninja.AuthBearer._verify_scopes") as mock_verify:
83
+ with patch("python_utils.django.api.ninja.AuthBearer._verify_scopes") as mock_verify:
84
84
  mock_verify.return_value = None
85
85
  yield mock_verify
86
86
 
@@ -1,4 +0,0 @@
1
- from .tenant_aware_task import TenantAwareTask
2
-
3
-
4
- __all__ = ["TenantAwareTask"]