svc-infra 0.1.595__py3-none-any.whl → 0.1.706__py3-none-any.whl
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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""Testing utilities for svc-infra applications.
|
|
2
|
+
|
|
3
|
+
This module provides mock implementations and test fixtures for
|
|
4
|
+
testing applications built with svc-infra, without requiring
|
|
5
|
+
real Redis, PostgreSQL, or other external services.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- MockCache: In-memory cache backend for tests
|
|
9
|
+
- MockJobQueue: Synchronous job queue for tests
|
|
10
|
+
- Test fixture factories for users and tenants
|
|
11
|
+
- Async test client utilities
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from svc_infra.testing import MockCache, MockJobQueue
|
|
15
|
+
>>>
|
|
16
|
+
>>> # Use mock cache in tests
|
|
17
|
+
>>> cache = MockCache()
|
|
18
|
+
>>> cache.set("key", "value", ttl=60)
|
|
19
|
+
>>> assert cache.get("key") == "value"
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Use mock job queue
|
|
22
|
+
>>> queue = MockJobQueue()
|
|
23
|
+
>>> queue.enqueue("send_email", {"to": "test@example.com"})
|
|
24
|
+
>>> assert len(queue.jobs) == 1
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import datetime, timedelta, timezone
|
|
33
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
|
34
|
+
|
|
35
|
+
# Type variable for generic model creation
|
|
36
|
+
T = TypeVar("T")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# =============================================================================
|
|
40
|
+
# Mock Cache
|
|
41
|
+
# =============================================================================
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CacheEntry:
|
|
46
|
+
"""Internal representation of a cached value."""
|
|
47
|
+
|
|
48
|
+
value: Any
|
|
49
|
+
expires_at: Optional[float] = None # Unix timestamp
|
|
50
|
+
|
|
51
|
+
def is_expired(self) -> bool:
|
|
52
|
+
"""Check if this entry has expired."""
|
|
53
|
+
if self.expires_at is None:
|
|
54
|
+
return False
|
|
55
|
+
return time.time() > self.expires_at
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MockCache:
|
|
59
|
+
"""
|
|
60
|
+
In-memory cache backend for testing.
|
|
61
|
+
|
|
62
|
+
Provides a simple synchronous cache that mimics the behavior of
|
|
63
|
+
Redis or other cache backends without external dependencies.
|
|
64
|
+
|
|
65
|
+
Features:
|
|
66
|
+
- TTL support with expiration
|
|
67
|
+
- Key prefix namespacing
|
|
68
|
+
- Pattern-based key deletion
|
|
69
|
+
- Thread-safe for single-threaded tests
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> cache = MockCache(prefix="test")
|
|
73
|
+
>>> cache.set("user:123", {"name": "Alice"}, ttl=300)
|
|
74
|
+
>>> cache.get("user:123")
|
|
75
|
+
{'name': 'Alice'}
|
|
76
|
+
>>> cache.delete("user:123")
|
|
77
|
+
>>> cache.get("user:123") is None
|
|
78
|
+
True
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, prefix: str = "test"):
|
|
82
|
+
"""
|
|
83
|
+
Initialize mock cache.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
prefix: Key prefix for namespacing (default: "test")
|
|
87
|
+
"""
|
|
88
|
+
self.prefix = prefix
|
|
89
|
+
self._store: Dict[str, CacheEntry] = {}
|
|
90
|
+
self._tags: Dict[str, set[str]] = {} # tag -> set of keys
|
|
91
|
+
|
|
92
|
+
def _prefixed_key(self, key: str) -> str:
|
|
93
|
+
"""Get the full key with prefix."""
|
|
94
|
+
return f"{self.prefix}:{key}"
|
|
95
|
+
|
|
96
|
+
def get(self, key: str) -> Optional[Any]:
|
|
97
|
+
"""
|
|
98
|
+
Get a value from the cache.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
key: Cache key
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Cached value or None if not found/expired
|
|
105
|
+
"""
|
|
106
|
+
full_key = self._prefixed_key(key)
|
|
107
|
+
entry = self._store.get(full_key)
|
|
108
|
+
if entry is None:
|
|
109
|
+
return None
|
|
110
|
+
if entry.is_expired():
|
|
111
|
+
del self._store[full_key]
|
|
112
|
+
return None
|
|
113
|
+
return entry.value
|
|
114
|
+
|
|
115
|
+
def set(
|
|
116
|
+
self,
|
|
117
|
+
key: str,
|
|
118
|
+
value: Any,
|
|
119
|
+
ttl: Optional[int] = None,
|
|
120
|
+
tags: Optional[List[str]] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Set a value in the cache.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
key: Cache key
|
|
127
|
+
value: Value to cache
|
|
128
|
+
ttl: Time-to-live in seconds (None for no expiration)
|
|
129
|
+
tags: Optional list of tags for grouped invalidation
|
|
130
|
+
"""
|
|
131
|
+
full_key = self._prefixed_key(key)
|
|
132
|
+
expires_at = time.time() + ttl if ttl else None
|
|
133
|
+
self._store[full_key] = CacheEntry(value=value, expires_at=expires_at)
|
|
134
|
+
|
|
135
|
+
# Track tags
|
|
136
|
+
if tags:
|
|
137
|
+
for tag in tags:
|
|
138
|
+
if tag not in self._tags:
|
|
139
|
+
self._tags[tag] = set()
|
|
140
|
+
self._tags[tag].add(full_key)
|
|
141
|
+
|
|
142
|
+
def delete(self, key: str) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Delete a key from the cache.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
key: Cache key
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if key existed, False otherwise
|
|
151
|
+
"""
|
|
152
|
+
full_key = self._prefixed_key(key)
|
|
153
|
+
if full_key in self._store:
|
|
154
|
+
del self._store[full_key]
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def delete_pattern(self, pattern: str) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Delete all keys matching a pattern.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
pattern: Pattern with * as wildcard (e.g., "user:*")
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Number of keys deleted
|
|
167
|
+
"""
|
|
168
|
+
import fnmatch
|
|
169
|
+
|
|
170
|
+
full_pattern = self._prefixed_key(pattern)
|
|
171
|
+
to_delete = [k for k in self._store if fnmatch.fnmatch(k, full_pattern)]
|
|
172
|
+
for key in to_delete:
|
|
173
|
+
del self._store[key]
|
|
174
|
+
return len(to_delete)
|
|
175
|
+
|
|
176
|
+
def delete_by_tag(self, tag: str) -> int:
|
|
177
|
+
"""
|
|
178
|
+
Delete all keys associated with a tag.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
tag: Tag name
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Number of keys deleted
|
|
185
|
+
"""
|
|
186
|
+
keys = self._tags.pop(tag, set())
|
|
187
|
+
count = 0
|
|
188
|
+
for key in keys:
|
|
189
|
+
if key in self._store:
|
|
190
|
+
del self._store[key]
|
|
191
|
+
count += 1
|
|
192
|
+
return count
|
|
193
|
+
|
|
194
|
+
def exists(self, key: str) -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Check if a key exists in the cache.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
key: Cache key
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True if key exists and is not expired
|
|
203
|
+
"""
|
|
204
|
+
return self.get(key) is not None
|
|
205
|
+
|
|
206
|
+
def clear(self) -> None:
|
|
207
|
+
"""Clear all cached values."""
|
|
208
|
+
self._store.clear()
|
|
209
|
+
self._tags.clear()
|
|
210
|
+
|
|
211
|
+
def keys(self, pattern: str = "*") -> List[str]:
|
|
212
|
+
"""
|
|
213
|
+
Get all keys matching a pattern.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
pattern: Pattern with * as wildcard
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of matching keys (without prefix)
|
|
220
|
+
"""
|
|
221
|
+
import fnmatch
|
|
222
|
+
|
|
223
|
+
full_pattern = self._prefixed_key(pattern)
|
|
224
|
+
prefix_len = len(self.prefix) + 1 # +1 for the colon
|
|
225
|
+
return [
|
|
226
|
+
k[prefix_len:]
|
|
227
|
+
for k in self._store
|
|
228
|
+
if fnmatch.fnmatch(k, full_pattern) and not self._store[k].is_expired()
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
def size(self) -> int:
|
|
232
|
+
"""Get the number of cached items (excluding expired)."""
|
|
233
|
+
# Clean up expired entries
|
|
234
|
+
now = time.time()
|
|
235
|
+
self._store = {
|
|
236
|
+
k: v
|
|
237
|
+
for k, v in self._store.items()
|
|
238
|
+
if v.expires_at is None or v.expires_at > now
|
|
239
|
+
}
|
|
240
|
+
return len(self._store)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# =============================================================================
|
|
244
|
+
# Mock Job Queue
|
|
245
|
+
# =============================================================================
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass
|
|
249
|
+
class MockJob:
|
|
250
|
+
"""Representation of a job in the mock queue."""
|
|
251
|
+
|
|
252
|
+
id: str
|
|
253
|
+
name: str
|
|
254
|
+
payload: Dict[str, Any]
|
|
255
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
256
|
+
available_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
257
|
+
attempts: int = 0
|
|
258
|
+
max_attempts: int = 5
|
|
259
|
+
status: str = "pending" # pending, processing, completed, failed
|
|
260
|
+
result: Optional[Any] = None
|
|
261
|
+
error: Optional[str] = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class MockJobQueue:
|
|
265
|
+
"""
|
|
266
|
+
Synchronous mock job queue for testing.
|
|
267
|
+
|
|
268
|
+
Jobs can be processed immediately (sync_mode=True) or queued
|
|
269
|
+
for manual processing. Useful for testing job handlers without
|
|
270
|
+
Redis or async complexity.
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
>>> queue = MockJobQueue()
|
|
274
|
+
>>>
|
|
275
|
+
>>> # Register a handler
|
|
276
|
+
>>> @queue.handler("send_email")
|
|
277
|
+
... def handle_email(payload):
|
|
278
|
+
... print(f"Sending to {payload['to']}")
|
|
279
|
+
...
|
|
280
|
+
>>> # Enqueue a job
|
|
281
|
+
>>> job = queue.enqueue("send_email", {"to": "test@example.com"})
|
|
282
|
+
>>>
|
|
283
|
+
>>> # Process all pending jobs
|
|
284
|
+
>>> queue.process_all()
|
|
285
|
+
Sending to test@example.com
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(self, sync_mode: bool = False):
|
|
289
|
+
"""
|
|
290
|
+
Initialize mock job queue.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
sync_mode: If True, execute jobs immediately on enqueue
|
|
294
|
+
"""
|
|
295
|
+
self.sync_mode = sync_mode
|
|
296
|
+
self._seq = 0
|
|
297
|
+
self._jobs: List[MockJob] = []
|
|
298
|
+
self._handlers: Dict[str, Callable[[Dict[str, Any]], Any]] = {}
|
|
299
|
+
self._completed: List[MockJob] = []
|
|
300
|
+
self._failed: List[MockJob] = []
|
|
301
|
+
|
|
302
|
+
def _next_id(self) -> str:
|
|
303
|
+
"""Generate next job ID."""
|
|
304
|
+
self._seq += 1
|
|
305
|
+
return f"job-{self._seq}"
|
|
306
|
+
|
|
307
|
+
def handler(self, name: str) -> Callable:
|
|
308
|
+
"""
|
|
309
|
+
Decorator to register a job handler.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
name: Job name to handle
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Decorator function
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def decorator(func: Callable[[Dict[str, Any]], Any]) -> Callable:
|
|
319
|
+
self._handlers[name] = func
|
|
320
|
+
return func
|
|
321
|
+
|
|
322
|
+
return decorator
|
|
323
|
+
|
|
324
|
+
def register_handler(
|
|
325
|
+
self, name: str, handler: Callable[[Dict[str, Any]], Any]
|
|
326
|
+
) -> None:
|
|
327
|
+
"""
|
|
328
|
+
Register a job handler function.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
name: Job name to handle
|
|
332
|
+
handler: Handler function that receives payload dict
|
|
333
|
+
"""
|
|
334
|
+
self._handlers[name] = handler
|
|
335
|
+
|
|
336
|
+
def enqueue(
|
|
337
|
+
self,
|
|
338
|
+
name: str,
|
|
339
|
+
payload: Dict[str, Any],
|
|
340
|
+
*,
|
|
341
|
+
delay_seconds: int = 0,
|
|
342
|
+
max_attempts: int = 5,
|
|
343
|
+
) -> MockJob:
|
|
344
|
+
"""
|
|
345
|
+
Enqueue a job.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
name: Job name (must have a registered handler to process)
|
|
349
|
+
payload: Job payload dictionary
|
|
350
|
+
delay_seconds: Delay before job becomes available
|
|
351
|
+
max_attempts: Maximum retry attempts
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The created MockJob
|
|
355
|
+
"""
|
|
356
|
+
available_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
|
|
357
|
+
job = MockJob(
|
|
358
|
+
id=self._next_id(),
|
|
359
|
+
name=name,
|
|
360
|
+
payload=dict(payload),
|
|
361
|
+
available_at=available_at,
|
|
362
|
+
max_attempts=max_attempts,
|
|
363
|
+
)
|
|
364
|
+
self._jobs.append(job)
|
|
365
|
+
|
|
366
|
+
if self.sync_mode and delay_seconds == 0:
|
|
367
|
+
self._process_job(job)
|
|
368
|
+
|
|
369
|
+
return job
|
|
370
|
+
|
|
371
|
+
def _process_job(self, job: MockJob) -> bool:
|
|
372
|
+
"""
|
|
373
|
+
Process a single job.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
True if job succeeded, False if failed
|
|
377
|
+
"""
|
|
378
|
+
handler = self._handlers.get(job.name)
|
|
379
|
+
if handler is None:
|
|
380
|
+
job.status = "failed"
|
|
381
|
+
job.error = f"No handler registered for job type: {job.name}"
|
|
382
|
+
self._failed.append(job)
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
job.attempts += 1
|
|
386
|
+
job.status = "processing"
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
result = handler(job.payload)
|
|
390
|
+
job.status = "completed"
|
|
391
|
+
job.result = result
|
|
392
|
+
self._completed.append(job)
|
|
393
|
+
return True
|
|
394
|
+
except Exception as e:
|
|
395
|
+
job.error = str(e)
|
|
396
|
+
if job.attempts >= job.max_attempts:
|
|
397
|
+
job.status = "failed"
|
|
398
|
+
self._failed.append(job)
|
|
399
|
+
else:
|
|
400
|
+
job.status = "pending"
|
|
401
|
+
# Exponential backoff
|
|
402
|
+
delay = 60 * job.attempts
|
|
403
|
+
job.available_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
def process_next(self) -> Optional[MockJob]:
|
|
407
|
+
"""
|
|
408
|
+
Process the next available job.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
The processed job, or None if no jobs available
|
|
412
|
+
"""
|
|
413
|
+
now = datetime.now(timezone.utc)
|
|
414
|
+
for job in self._jobs:
|
|
415
|
+
if job.status == "pending" and job.available_at <= now:
|
|
416
|
+
self._process_job(job)
|
|
417
|
+
if job.status in ("completed", "failed"):
|
|
418
|
+
self._jobs.remove(job)
|
|
419
|
+
return job
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
def process_all(self) -> int:
|
|
423
|
+
"""
|
|
424
|
+
Process all available jobs.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Number of jobs processed
|
|
428
|
+
"""
|
|
429
|
+
count = 0
|
|
430
|
+
while self.process_next() is not None:
|
|
431
|
+
count += 1
|
|
432
|
+
return count
|
|
433
|
+
|
|
434
|
+
@property
|
|
435
|
+
def jobs(self) -> List[MockJob]:
|
|
436
|
+
"""Get all pending jobs."""
|
|
437
|
+
return [j for j in self._jobs if j.status == "pending"]
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def completed_jobs(self) -> List[MockJob]:
|
|
441
|
+
"""Get all completed jobs."""
|
|
442
|
+
return self._completed.copy()
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def failed_jobs(self) -> List[MockJob]:
|
|
446
|
+
"""Get all failed jobs."""
|
|
447
|
+
return self._failed.copy()
|
|
448
|
+
|
|
449
|
+
def clear(self) -> None:
|
|
450
|
+
"""Clear all jobs (pending, completed, and failed)."""
|
|
451
|
+
self._jobs.clear()
|
|
452
|
+
self._completed.clear()
|
|
453
|
+
self._failed.clear()
|
|
454
|
+
|
|
455
|
+
def get_job(self, job_id: str) -> Optional[MockJob]:
|
|
456
|
+
"""
|
|
457
|
+
Get a job by ID.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
job_id: Job ID
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
The job or None if not found
|
|
464
|
+
"""
|
|
465
|
+
for job in self._jobs + self._completed + self._failed:
|
|
466
|
+
if job.id == job_id:
|
|
467
|
+
return job
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# =============================================================================
|
|
472
|
+
# Test Fixture Factories
|
|
473
|
+
# =============================================================================
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def generate_uuid() -> str:
|
|
477
|
+
"""Generate a random UUID string."""
|
|
478
|
+
return str(uuid.uuid4())
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def generate_email(prefix: str = "test") -> str:
|
|
482
|
+
"""Generate a unique test email address."""
|
|
483
|
+
return f"{prefix}+{uuid.uuid4().hex[:8]}@example.com"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@dataclass
|
|
487
|
+
class UserFixtureData:
|
|
488
|
+
"""Data for creating a test user."""
|
|
489
|
+
|
|
490
|
+
id: str = field(default_factory=generate_uuid)
|
|
491
|
+
email: str = field(default_factory=lambda: generate_email("user"))
|
|
492
|
+
hashed_password: str = "$2b$12$test.hashed.password.placeholder"
|
|
493
|
+
is_active: bool = True
|
|
494
|
+
is_verified: bool = True
|
|
495
|
+
is_superuser: bool = False
|
|
496
|
+
full_name: Optional[str] = None
|
|
497
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass
|
|
501
|
+
class TenantFixtureData:
|
|
502
|
+
"""Data for creating a test tenant."""
|
|
503
|
+
|
|
504
|
+
id: str = field(default_factory=generate_uuid)
|
|
505
|
+
name: str = field(default_factory=lambda: f"Test Tenant {uuid.uuid4().hex[:6]}")
|
|
506
|
+
slug: Optional[str] = None
|
|
507
|
+
is_active: bool = True
|
|
508
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
509
|
+
|
|
510
|
+
def __post_init__(self):
|
|
511
|
+
if self.slug is None:
|
|
512
|
+
self.slug = self.name.lower().replace(" ", "-")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def create_test_user_data(**overrides: Any) -> UserFixtureData:
|
|
516
|
+
"""
|
|
517
|
+
Create test user data with optional overrides.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
**overrides: Fields to override (id, email, is_superuser, etc.)
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
UserFixtureData instance
|
|
524
|
+
|
|
525
|
+
Example:
|
|
526
|
+
>>> user_data = create_test_user_data(is_superuser=True)
|
|
527
|
+
>>> print(user_data.is_superuser)
|
|
528
|
+
True
|
|
529
|
+
"""
|
|
530
|
+
return UserFixtureData(**overrides)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def create_test_tenant_data(**overrides: Any) -> TenantFixtureData:
|
|
534
|
+
"""
|
|
535
|
+
Create test tenant data with optional overrides.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
**overrides: Fields to override (id, name, slug, etc.)
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
TenantFixtureData instance
|
|
542
|
+
|
|
543
|
+
Example:
|
|
544
|
+
>>> tenant_data = create_test_tenant_data(name="Acme Corp")
|
|
545
|
+
>>> print(tenant_data.slug)
|
|
546
|
+
'acme-corp'
|
|
547
|
+
"""
|
|
548
|
+
return TenantFixtureData(**overrides)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
async def create_test_user(
|
|
552
|
+
session: Any,
|
|
553
|
+
user_model: Callable[..., T],
|
|
554
|
+
**overrides: Any,
|
|
555
|
+
) -> T:
|
|
556
|
+
"""
|
|
557
|
+
Create a test user in the database.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
session: SQLAlchemy async session
|
|
561
|
+
user_model: User model class (must accept id, email, hashed_password,
|
|
562
|
+
is_active, is_verified, is_superuser as kwargs)
|
|
563
|
+
**overrides: Field overrides
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Created user instance
|
|
567
|
+
|
|
568
|
+
Example:
|
|
569
|
+
>>> async with async_session() as session:
|
|
570
|
+
... user = await create_test_user(session, User, is_superuser=True)
|
|
571
|
+
... print(user.email)
|
|
572
|
+
"""
|
|
573
|
+
data = create_test_user_data(**overrides)
|
|
574
|
+
user = user_model(
|
|
575
|
+
id=data.id,
|
|
576
|
+
email=data.email,
|
|
577
|
+
hashed_password=data.hashed_password,
|
|
578
|
+
is_active=data.is_active,
|
|
579
|
+
is_verified=data.is_verified,
|
|
580
|
+
is_superuser=data.is_superuser,
|
|
581
|
+
**data.extra,
|
|
582
|
+
)
|
|
583
|
+
if hasattr(user, "full_name") and data.full_name:
|
|
584
|
+
user.full_name = data.full_name
|
|
585
|
+
|
|
586
|
+
session.add(user)
|
|
587
|
+
await session.commit()
|
|
588
|
+
await session.refresh(user)
|
|
589
|
+
return user
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def create_test_tenant(
|
|
593
|
+
session: Any,
|
|
594
|
+
tenant_model: Callable[..., T],
|
|
595
|
+
**overrides: Any,
|
|
596
|
+
) -> T:
|
|
597
|
+
"""
|
|
598
|
+
Create a test tenant in the database.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
session: SQLAlchemy async session
|
|
602
|
+
tenant_model: Tenant model class
|
|
603
|
+
**overrides: Field overrides
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Created tenant instance
|
|
607
|
+
|
|
608
|
+
Example:
|
|
609
|
+
>>> async with async_session() as session:
|
|
610
|
+
... tenant = await create_test_tenant(session, Tenant, name="Test Co")
|
|
611
|
+
... print(tenant.slug)
|
|
612
|
+
"""
|
|
613
|
+
data = create_test_tenant_data(**overrides)
|
|
614
|
+
tenant = tenant_model(
|
|
615
|
+
id=data.id,
|
|
616
|
+
name=data.name,
|
|
617
|
+
slug=data.slug,
|
|
618
|
+
is_active=data.is_active,
|
|
619
|
+
**data.extra,
|
|
620
|
+
)
|
|
621
|
+
session.add(tenant)
|
|
622
|
+
await session.commit()
|
|
623
|
+
await session.refresh(tenant)
|
|
624
|
+
return tenant
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# =============================================================================
|
|
628
|
+
# Pytest Fixtures (importable for conftest.py)
|
|
629
|
+
# =============================================================================
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def pytest_fixtures() -> Dict[str, Callable]:
|
|
633
|
+
"""
|
|
634
|
+
Get pytest fixture functions for use in conftest.py.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
Dictionary of fixture name -> fixture function
|
|
638
|
+
|
|
639
|
+
Example:
|
|
640
|
+
In your conftest.py:
|
|
641
|
+
>>> from svc_infra.testing import pytest_fixtures
|
|
642
|
+
>>> import pytest
|
|
643
|
+
>>>
|
|
644
|
+
>>> fixtures = pytest_fixtures()
|
|
645
|
+
>>> mock_cache = pytest.fixture(fixtures["mock_cache"])
|
|
646
|
+
>>> mock_job_queue = pytest.fixture(fixtures["mock_job_queue"])
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
def mock_cache() -> MockCache:
|
|
650
|
+
"""Provide a fresh MockCache for each test."""
|
|
651
|
+
return MockCache()
|
|
652
|
+
|
|
653
|
+
def mock_job_queue() -> MockJobQueue:
|
|
654
|
+
"""Provide a fresh MockJobQueue for each test."""
|
|
655
|
+
return MockJobQueue()
|
|
656
|
+
|
|
657
|
+
def sync_job_queue() -> MockJobQueue:
|
|
658
|
+
"""Provide a MockJobQueue that executes jobs immediately."""
|
|
659
|
+
return MockJobQueue(sync_mode=True)
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
"mock_cache": mock_cache,
|
|
663
|
+
"mock_job_queue": mock_job_queue,
|
|
664
|
+
"sync_job_queue": sync_job_queue,
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
__all__ = [
|
|
669
|
+
# Mock implementations
|
|
670
|
+
"MockCache",
|
|
671
|
+
"MockJobQueue",
|
|
672
|
+
"MockJob",
|
|
673
|
+
"CacheEntry",
|
|
674
|
+
# Test data factories
|
|
675
|
+
"UserFixtureData",
|
|
676
|
+
"TenantFixtureData",
|
|
677
|
+
"create_test_user_data",
|
|
678
|
+
"create_test_tenant_data",
|
|
679
|
+
"create_test_user",
|
|
680
|
+
"create_test_tenant",
|
|
681
|
+
# Utilities
|
|
682
|
+
"generate_uuid",
|
|
683
|
+
"generate_email",
|
|
684
|
+
"pytest_fixtures",
|
|
685
|
+
]
|
svc_infra/utils.py
CHANGED
|
@@ -4,9 +4,11 @@ from string import Template as _T
|
|
|
4
4
|
from typing import Any, Dict
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def render_template(
|
|
7
|
+
def render_template(
|
|
8
|
+
tmpl_dir: str, name: str, subs: dict[str, Any] | None = None
|
|
9
|
+
) -> str:
|
|
8
10
|
txt = pkg.files(tmpl_dir).joinpath(name).read_text(encoding="utf-8")
|
|
9
|
-
return _T(txt).safe_substitute(subs)
|
|
11
|
+
return _T(txt).safe_substitute(subs or {})
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
|
|
@@ -18,6 +20,8 @@ def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
|
|
|
18
20
|
return {"path": str(dest), "action": "wrote"}
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
def ensure_init_py(
|
|
23
|
+
def ensure_init_py(
|
|
24
|
+
dir_path: Path, overwrite: bool, paired: bool, content: str
|
|
25
|
+
) -> Dict[str, Any]:
|
|
22
26
|
"""Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
|
|
23
27
|
return write(dir_path / "__init__.py", content, overwrite)
|