svc-infra 0.1.706__py3-none-any.whl → 1.1.0__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/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/storage/easy.py
CHANGED
|
@@ -6,7 +6,6 @@ Simplifies storage backend initialization with sensible defaults.
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
from .backends import LocalBackend, MemoryBackend, S3Backend
|
|
12
11
|
from .base import StorageBackend
|
|
@@ -16,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
def easy_storage(
|
|
19
|
-
backend:
|
|
18
|
+
backend: str | None = None,
|
|
20
19
|
**kwargs,
|
|
21
20
|
) -> StorageBackend:
|
|
22
21
|
"""
|
|
@@ -135,9 +134,7 @@ def easy_storage(
|
|
|
135
134
|
secret_key = kwargs.get("secret_key")
|
|
136
135
|
|
|
137
136
|
if not access_key or not secret_key:
|
|
138
|
-
access_key_from_settings, secret_key_from_settings = (
|
|
139
|
-
settings.get_s3_credentials()
|
|
140
|
-
)
|
|
137
|
+
access_key_from_settings, secret_key_from_settings = settings.get_s3_credentials()
|
|
141
138
|
access_key = access_key or access_key_from_settings
|
|
142
139
|
secret_key = secret_key or secret_key_from_settings
|
|
143
140
|
|
|
@@ -165,14 +162,13 @@ def easy_storage(
|
|
|
165
162
|
elif backend_type == "gcs":
|
|
166
163
|
# Google Cloud Storage backend
|
|
167
164
|
raise NotImplementedError(
|
|
168
|
-
"GCS backend not yet implemented.
|
|
165
|
+
"GCS backend not yet implemented. Use 'local' or 's3' backend for now."
|
|
169
166
|
)
|
|
170
167
|
|
|
171
168
|
elif backend_type == "cloudinary":
|
|
172
169
|
# Cloudinary backend
|
|
173
170
|
raise NotImplementedError(
|
|
174
|
-
"Cloudinary backend not yet implemented. "
|
|
175
|
-
"Use 'local' or 's3' backend for now."
|
|
171
|
+
"Cloudinary backend not yet implemented. Use 'local' or 's3' backend for now."
|
|
176
172
|
)
|
|
177
173
|
|
|
178
174
|
else:
|
svc_infra/storage/settings.py
CHANGED
|
@@ -5,7 +5,7 @@ Handles environment-based configuration and auto-detection of storage backends.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
|
-
from typing import Literal
|
|
8
|
+
from typing import Literal
|
|
9
9
|
|
|
10
10
|
from pydantic import Field
|
|
11
11
|
from pydantic_settings import BaseSettings
|
|
@@ -52,11 +52,9 @@ class StorageSettings(BaseSettings):
|
|
|
52
52
|
"""
|
|
53
53
|
|
|
54
54
|
# Backend selection
|
|
55
|
-
storage_backend:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
description="Storage backend type (auto-detected if not set)",
|
|
59
|
-
)
|
|
55
|
+
storage_backend: Literal["local", "s3", "gcs", "cloudinary", "memory"] | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="Storage backend type (auto-detected if not set)",
|
|
60
58
|
)
|
|
61
59
|
|
|
62
60
|
# Local backend settings
|
|
@@ -68,13 +66,13 @@ class StorageSettings(BaseSettings):
|
|
|
68
66
|
default="http://localhost:8000/files",
|
|
69
67
|
description="Base URL for serving files",
|
|
70
68
|
)
|
|
71
|
-
storage_signing_secret:
|
|
69
|
+
storage_signing_secret: str | None = Field(
|
|
72
70
|
default=None,
|
|
73
71
|
description="Secret key for URL signing (auto-generated if not set)",
|
|
74
72
|
)
|
|
75
73
|
|
|
76
74
|
# S3 backend settings
|
|
77
|
-
storage_s3_bucket:
|
|
75
|
+
storage_s3_bucket: str | None = Field(
|
|
78
76
|
default=None,
|
|
79
77
|
description="S3 bucket name",
|
|
80
78
|
)
|
|
@@ -82,43 +80,43 @@ class StorageSettings(BaseSettings):
|
|
|
82
80
|
default="us-east-1",
|
|
83
81
|
description="AWS region",
|
|
84
82
|
)
|
|
85
|
-
storage_s3_endpoint:
|
|
83
|
+
storage_s3_endpoint: str | None = Field(
|
|
86
84
|
default=None,
|
|
87
85
|
description="Custom S3 endpoint (for DigitalOcean Spaces, Wasabi, etc.)",
|
|
88
86
|
)
|
|
89
|
-
storage_s3_access_key:
|
|
87
|
+
storage_s3_access_key: str | None = Field(
|
|
90
88
|
default=None,
|
|
91
89
|
description="S3 access key (falls back to AWS_ACCESS_KEY_ID)",
|
|
92
90
|
)
|
|
93
|
-
storage_s3_secret_key:
|
|
91
|
+
storage_s3_secret_key: str | None = Field(
|
|
94
92
|
default=None,
|
|
95
93
|
description="S3 secret key (falls back to AWS_SECRET_ACCESS_KEY)",
|
|
96
94
|
)
|
|
97
95
|
|
|
98
96
|
# GCS backend settings
|
|
99
|
-
storage_gcs_bucket:
|
|
97
|
+
storage_gcs_bucket: str | None = Field(
|
|
100
98
|
default=None,
|
|
101
99
|
description="Google Cloud Storage bucket name",
|
|
102
100
|
)
|
|
103
|
-
storage_gcs_project:
|
|
101
|
+
storage_gcs_project: str | None = Field(
|
|
104
102
|
default=None,
|
|
105
103
|
description="GCP project ID",
|
|
106
104
|
)
|
|
107
|
-
storage_gcs_credentials_path:
|
|
105
|
+
storage_gcs_credentials_path: str | None = Field(
|
|
108
106
|
default=None,
|
|
109
107
|
description="Path to GCP service account JSON",
|
|
110
108
|
)
|
|
111
109
|
|
|
112
110
|
# Cloudinary backend settings
|
|
113
|
-
storage_cloudinary_cloud_name:
|
|
111
|
+
storage_cloudinary_cloud_name: str | None = Field(
|
|
114
112
|
default=None,
|
|
115
113
|
description="Cloudinary cloud name",
|
|
116
114
|
)
|
|
117
|
-
storage_cloudinary_api_key:
|
|
115
|
+
storage_cloudinary_api_key: str | None = Field(
|
|
118
116
|
default=None,
|
|
119
117
|
description="Cloudinary API key",
|
|
120
118
|
)
|
|
121
|
-
storage_cloudinary_api_secret:
|
|
119
|
+
storage_cloudinary_api_secret: str | None = Field(
|
|
122
120
|
default=None,
|
|
123
121
|
description="Cloudinary API secret",
|
|
124
122
|
)
|
|
@@ -176,7 +174,7 @@ class StorageSettings(BaseSettings):
|
|
|
176
174
|
# Default to memory (for development/testing)
|
|
177
175
|
return "memory"
|
|
178
176
|
|
|
179
|
-
def get_s3_credentials(self) -> tuple[
|
|
177
|
+
def get_s3_credentials(self) -> tuple[str | None, str | None]:
|
|
180
178
|
"""
|
|
181
179
|
Get S3 credentials with fallback to AWS environment variables.
|
|
182
180
|
|
svc_infra/testing/__init__.py
CHANGED
|
@@ -28,9 +28,10 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import time
|
|
30
30
|
import uuid
|
|
31
|
+
from collections.abc import Callable
|
|
31
32
|
from dataclasses import dataclass, field
|
|
32
|
-
from datetime import datetime, timedelta
|
|
33
|
-
from typing import Any,
|
|
33
|
+
from datetime import UTC, datetime, timedelta
|
|
34
|
+
from typing import Any, TypeVar
|
|
34
35
|
|
|
35
36
|
# Type variable for generic model creation
|
|
36
37
|
T = TypeVar("T")
|
|
@@ -46,7 +47,7 @@ class CacheEntry:
|
|
|
46
47
|
"""Internal representation of a cached value."""
|
|
47
48
|
|
|
48
49
|
value: Any
|
|
49
|
-
expires_at:
|
|
50
|
+
expires_at: float | None = None # Unix timestamp
|
|
50
51
|
|
|
51
52
|
def is_expired(self) -> bool:
|
|
52
53
|
"""Check if this entry has expired."""
|
|
@@ -86,14 +87,14 @@ class MockCache:
|
|
|
86
87
|
prefix: Key prefix for namespacing (default: "test")
|
|
87
88
|
"""
|
|
88
89
|
self.prefix = prefix
|
|
89
|
-
self._store:
|
|
90
|
-
self._tags:
|
|
90
|
+
self._store: dict[str, CacheEntry] = {}
|
|
91
|
+
self._tags: dict[str, set[str]] = {} # tag -> set of keys
|
|
91
92
|
|
|
92
93
|
def _prefixed_key(self, key: str) -> str:
|
|
93
94
|
"""Get the full key with prefix."""
|
|
94
95
|
return f"{self.prefix}:{key}"
|
|
95
96
|
|
|
96
|
-
def get(self, key: str) ->
|
|
97
|
+
def get(self, key: str) -> Any | None:
|
|
97
98
|
"""
|
|
98
99
|
Get a value from the cache.
|
|
99
100
|
|
|
@@ -116,8 +117,8 @@ class MockCache:
|
|
|
116
117
|
self,
|
|
117
118
|
key: str,
|
|
118
119
|
value: Any,
|
|
119
|
-
ttl:
|
|
120
|
-
tags:
|
|
120
|
+
ttl: int | None = None,
|
|
121
|
+
tags: list[str] | None = None,
|
|
121
122
|
) -> None:
|
|
122
123
|
"""
|
|
123
124
|
Set a value in the cache.
|
|
@@ -208,7 +209,7 @@ class MockCache:
|
|
|
208
209
|
self._store.clear()
|
|
209
210
|
self._tags.clear()
|
|
210
211
|
|
|
211
|
-
def keys(self, pattern: str = "*") ->
|
|
212
|
+
def keys(self, pattern: str = "*") -> list[str]:
|
|
212
213
|
"""
|
|
213
214
|
Get all keys matching a pattern.
|
|
214
215
|
|
|
@@ -233,9 +234,7 @@ class MockCache:
|
|
|
233
234
|
# Clean up expired entries
|
|
234
235
|
now = time.time()
|
|
235
236
|
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
|
|
237
|
+
k: v for k, v in self._store.items() if v.expires_at is None or v.expires_at > now
|
|
239
238
|
}
|
|
240
239
|
return len(self._store)
|
|
241
240
|
|
|
@@ -251,14 +250,14 @@ class MockJob:
|
|
|
251
250
|
|
|
252
251
|
id: str
|
|
253
252
|
name: str
|
|
254
|
-
payload:
|
|
255
|
-
created_at: datetime = field(default_factory=lambda: datetime.now(
|
|
256
|
-
available_at: datetime = field(default_factory=lambda: datetime.now(
|
|
253
|
+
payload: dict[str, Any]
|
|
254
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
255
|
+
available_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
257
256
|
attempts: int = 0
|
|
258
257
|
max_attempts: int = 5
|
|
259
258
|
status: str = "pending" # pending, processing, completed, failed
|
|
260
|
-
result:
|
|
261
|
-
error:
|
|
259
|
+
result: Any | None = None
|
|
260
|
+
error: str | None = None
|
|
262
261
|
|
|
263
262
|
|
|
264
263
|
class MockJobQueue:
|
|
@@ -294,10 +293,10 @@ class MockJobQueue:
|
|
|
294
293
|
"""
|
|
295
294
|
self.sync_mode = sync_mode
|
|
296
295
|
self._seq = 0
|
|
297
|
-
self._jobs:
|
|
298
|
-
self._handlers:
|
|
299
|
-
self._completed:
|
|
300
|
-
self._failed:
|
|
296
|
+
self._jobs: list[MockJob] = []
|
|
297
|
+
self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = {}
|
|
298
|
+
self._completed: list[MockJob] = []
|
|
299
|
+
self._failed: list[MockJob] = []
|
|
301
300
|
|
|
302
301
|
def _next_id(self) -> str:
|
|
303
302
|
"""Generate next job ID."""
|
|
@@ -315,15 +314,13 @@ class MockJobQueue:
|
|
|
315
314
|
Decorator function
|
|
316
315
|
"""
|
|
317
316
|
|
|
318
|
-
def decorator(func: Callable[[
|
|
317
|
+
def decorator(func: Callable[[dict[str, Any]], Any]) -> Callable:
|
|
319
318
|
self._handlers[name] = func
|
|
320
319
|
return func
|
|
321
320
|
|
|
322
321
|
return decorator
|
|
323
322
|
|
|
324
|
-
def register_handler(
|
|
325
|
-
self, name: str, handler: Callable[[Dict[str, Any]], Any]
|
|
326
|
-
) -> None:
|
|
323
|
+
def register_handler(self, name: str, handler: Callable[[dict[str, Any]], Any]) -> None:
|
|
327
324
|
"""
|
|
328
325
|
Register a job handler function.
|
|
329
326
|
|
|
@@ -336,7 +333,7 @@ class MockJobQueue:
|
|
|
336
333
|
def enqueue(
|
|
337
334
|
self,
|
|
338
335
|
name: str,
|
|
339
|
-
payload:
|
|
336
|
+
payload: dict[str, Any],
|
|
340
337
|
*,
|
|
341
338
|
delay_seconds: int = 0,
|
|
342
339
|
max_attempts: int = 5,
|
|
@@ -353,7 +350,7 @@ class MockJobQueue:
|
|
|
353
350
|
Returns:
|
|
354
351
|
The created MockJob
|
|
355
352
|
"""
|
|
356
|
-
available_at = datetime.now(
|
|
353
|
+
available_at = datetime.now(UTC) + timedelta(seconds=delay_seconds)
|
|
357
354
|
job = MockJob(
|
|
358
355
|
id=self._next_id(),
|
|
359
356
|
name=name,
|
|
@@ -400,17 +397,17 @@ class MockJobQueue:
|
|
|
400
397
|
job.status = "pending"
|
|
401
398
|
# Exponential backoff
|
|
402
399
|
delay = 60 * job.attempts
|
|
403
|
-
job.available_at = datetime.now(
|
|
400
|
+
job.available_at = datetime.now(UTC) + timedelta(seconds=delay)
|
|
404
401
|
return False
|
|
405
402
|
|
|
406
|
-
def process_next(self) ->
|
|
403
|
+
def process_next(self) -> MockJob | None:
|
|
407
404
|
"""
|
|
408
405
|
Process the next available job.
|
|
409
406
|
|
|
410
407
|
Returns:
|
|
411
408
|
The processed job, or None if no jobs available
|
|
412
409
|
"""
|
|
413
|
-
now = datetime.now(
|
|
410
|
+
now = datetime.now(UTC)
|
|
414
411
|
for job in self._jobs:
|
|
415
412
|
if job.status == "pending" and job.available_at <= now:
|
|
416
413
|
self._process_job(job)
|
|
@@ -432,17 +429,17 @@ class MockJobQueue:
|
|
|
432
429
|
return count
|
|
433
430
|
|
|
434
431
|
@property
|
|
435
|
-
def jobs(self) ->
|
|
432
|
+
def jobs(self) -> list[MockJob]:
|
|
436
433
|
"""Get all pending jobs."""
|
|
437
434
|
return [j for j in self._jobs if j.status == "pending"]
|
|
438
435
|
|
|
439
436
|
@property
|
|
440
|
-
def completed_jobs(self) ->
|
|
437
|
+
def completed_jobs(self) -> list[MockJob]:
|
|
441
438
|
"""Get all completed jobs."""
|
|
442
439
|
return self._completed.copy()
|
|
443
440
|
|
|
444
441
|
@property
|
|
445
|
-
def failed_jobs(self) ->
|
|
442
|
+
def failed_jobs(self) -> list[MockJob]:
|
|
446
443
|
"""Get all failed jobs."""
|
|
447
444
|
return self._failed.copy()
|
|
448
445
|
|
|
@@ -452,7 +449,7 @@ class MockJobQueue:
|
|
|
452
449
|
self._completed.clear()
|
|
453
450
|
self._failed.clear()
|
|
454
451
|
|
|
455
|
-
def get_job(self, job_id: str) ->
|
|
452
|
+
def get_job(self, job_id: str) -> MockJob | None:
|
|
456
453
|
"""
|
|
457
454
|
Get a job by ID.
|
|
458
455
|
|
|
@@ -493,8 +490,8 @@ class UserFixtureData:
|
|
|
493
490
|
is_active: bool = True
|
|
494
491
|
is_verified: bool = True
|
|
495
492
|
is_superuser: bool = False
|
|
496
|
-
full_name:
|
|
497
|
-
extra:
|
|
493
|
+
full_name: str | None = None
|
|
494
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
498
495
|
|
|
499
496
|
|
|
500
497
|
@dataclass
|
|
@@ -503,9 +500,9 @@ class TenantFixtureData:
|
|
|
503
500
|
|
|
504
501
|
id: str = field(default_factory=generate_uuid)
|
|
505
502
|
name: str = field(default_factory=lambda: f"Test Tenant {uuid.uuid4().hex[:6]}")
|
|
506
|
-
slug:
|
|
503
|
+
slug: str | None = None
|
|
507
504
|
is_active: bool = True
|
|
508
|
-
extra:
|
|
505
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
509
506
|
|
|
510
507
|
def __post_init__(self):
|
|
511
508
|
if self.slug is None:
|
|
@@ -629,7 +626,7 @@ async def create_test_tenant(
|
|
|
629
626
|
# =============================================================================
|
|
630
627
|
|
|
631
628
|
|
|
632
|
-
def pytest_fixtures() ->
|
|
629
|
+
def pytest_fixtures() -> dict[str, Callable]:
|
|
633
630
|
"""
|
|
634
631
|
Get pytest fixture functions for use in conftest.py.
|
|
635
632
|
|
svc_infra/utils.py
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
|
+
"""svc-infra utilities module.
|
|
2
|
+
|
|
3
|
+
This module provides utility functions and helpers for svc-infra, including:
|
|
4
|
+
- Template rendering and file writing utilities
|
|
5
|
+
- Deprecation decorators and warnings
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
1
11
|
import importlib.resources as pkg
|
|
12
|
+
import warnings
|
|
13
|
+
from collections.abc import Callable
|
|
2
14
|
from pathlib import Path
|
|
3
15
|
from string import Template as _T
|
|
4
|
-
from typing import Any,
|
|
16
|
+
from typing import Any, TypeVar
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
# Template utilities
|
|
20
|
+
"render_template",
|
|
21
|
+
"write",
|
|
22
|
+
"ensure_init_py",
|
|
23
|
+
# Deprecation utilities
|
|
24
|
+
"deprecated",
|
|
25
|
+
"deprecated_parameter",
|
|
26
|
+
"DeprecatedWarning",
|
|
27
|
+
]
|
|
28
|
+
|
|
5
29
|
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Template Utilities
|
|
32
|
+
# =============================================================================
|
|
6
33
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
) -> str:
|
|
34
|
+
|
|
35
|
+
def render_template(tmpl_dir: str, name: str, subs: dict[str, Any] | None = None) -> str:
|
|
10
36
|
txt = pkg.files(tmpl_dir).joinpath(name).read_text(encoding="utf-8")
|
|
11
37
|
return _T(txt).safe_substitute(subs or {})
|
|
12
38
|
|
|
13
39
|
|
|
14
|
-
def write(dest: Path, content: str, overwrite: bool = False) ->
|
|
40
|
+
def write(dest: Path, content: str, overwrite: bool = False) -> dict[str, Any]:
|
|
15
41
|
dest = dest.resolve()
|
|
16
42
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
17
43
|
if dest.exists() and not overwrite:
|
|
@@ -20,8 +46,143 @@ def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
|
|
|
20
46
|
return {"path": str(dest), "action": "wrote"}
|
|
21
47
|
|
|
22
48
|
|
|
23
|
-
def ensure_init_py(
|
|
24
|
-
dir_path: Path, overwrite: bool, paired: bool, content: str
|
|
25
|
-
) -> Dict[str, Any]:
|
|
49
|
+
def ensure_init_py(dir_path: Path, overwrite: bool, paired: bool, content: str) -> dict[str, Any]:
|
|
26
50
|
"""Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
|
|
27
51
|
return write(dir_path / "__init__.py", content, overwrite)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# =============================================================================
|
|
55
|
+
# Deprecation Utilities
|
|
56
|
+
# =============================================================================
|
|
57
|
+
|
|
58
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeprecatedWarning(DeprecationWarning):
|
|
62
|
+
"""Custom deprecation warning for svc-infra.
|
|
63
|
+
|
|
64
|
+
This warning is used to distinguish svc-infra deprecations from
|
|
65
|
+
Python's built-in DeprecationWarning.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def deprecated(
|
|
72
|
+
version: str,
|
|
73
|
+
reason: str,
|
|
74
|
+
removal_version: str | None = None,
|
|
75
|
+
*,
|
|
76
|
+
stacklevel: int = 2,
|
|
77
|
+
) -> Callable[[F], F]:
|
|
78
|
+
"""Decorator to mark a function or class as deprecated.
|
|
79
|
+
|
|
80
|
+
The decorated function/class will emit a DeprecationWarning when called/instantiated.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
version: The version in which the feature was deprecated (e.g., "1.2.0").
|
|
84
|
+
reason: The reason for deprecation and recommended alternative.
|
|
85
|
+
removal_version: The version in which the feature will be removed (e.g., "1.4.0").
|
|
86
|
+
stacklevel: Stack level for the warning (default 2 for immediate caller).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A decorator that wraps the function/class with deprecation warning.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
>>> @deprecated(
|
|
93
|
+
... version="1.2.0",
|
|
94
|
+
... reason="Use new_function() instead",
|
|
95
|
+
... removal_version="1.4.0"
|
|
96
|
+
... )
|
|
97
|
+
... def old_function():
|
|
98
|
+
... return "result"
|
|
99
|
+
>>>
|
|
100
|
+
>>> old_function() # Emits DeprecationWarning
|
|
101
|
+
'result'
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def decorator(func: F) -> F:
|
|
105
|
+
# Build the warning message
|
|
106
|
+
name = getattr(func, "__qualname__", getattr(func, "__name__", str(func)))
|
|
107
|
+
message = f"{name} is deprecated since version {version}."
|
|
108
|
+
|
|
109
|
+
if removal_version:
|
|
110
|
+
message += f" It will be removed in version {removal_version}."
|
|
111
|
+
|
|
112
|
+
message += f" {reason}"
|
|
113
|
+
|
|
114
|
+
if isinstance(func, type):
|
|
115
|
+
# Handle class deprecation
|
|
116
|
+
original_init = func.__init__ # type: ignore[misc]
|
|
117
|
+
|
|
118
|
+
@functools.wraps(original_init)
|
|
119
|
+
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
120
|
+
warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel)
|
|
121
|
+
original_init(self, *args, **kwargs)
|
|
122
|
+
|
|
123
|
+
func.__init__ = new_init # type: ignore[misc]
|
|
124
|
+
|
|
125
|
+
# Add deprecation info to docstring
|
|
126
|
+
if func.__doc__:
|
|
127
|
+
func.__doc__ = f".. deprecated:: {version}\n {reason}\n\n{func.__doc__}"
|
|
128
|
+
else:
|
|
129
|
+
func.__doc__ = f".. deprecated:: {version}\n {reason}"
|
|
130
|
+
|
|
131
|
+
return func # type: ignore[return-value]
|
|
132
|
+
else:
|
|
133
|
+
# Handle function deprecation
|
|
134
|
+
@functools.wraps(func)
|
|
135
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
136
|
+
warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel)
|
|
137
|
+
return func(*args, **kwargs)
|
|
138
|
+
|
|
139
|
+
# Add deprecation info to docstring
|
|
140
|
+
if wrapper.__doc__:
|
|
141
|
+
wrapper.__doc__ = f".. deprecated:: {version}\n {reason}\n\n{wrapper.__doc__}"
|
|
142
|
+
else:
|
|
143
|
+
wrapper.__doc__ = f".. deprecated:: {version}\n {reason}"
|
|
144
|
+
|
|
145
|
+
return wrapper # type: ignore[return-value]
|
|
146
|
+
|
|
147
|
+
return decorator
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def deprecated_parameter(
|
|
151
|
+
name: str,
|
|
152
|
+
version: str,
|
|
153
|
+
reason: str,
|
|
154
|
+
removal_version: str | None = None,
|
|
155
|
+
*,
|
|
156
|
+
stacklevel: int = 2,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Emit a deprecation warning for a deprecated parameter.
|
|
159
|
+
|
|
160
|
+
Call this function when a deprecated parameter is used. This should be
|
|
161
|
+
called at the beginning of a function that has deprecated parameters.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: The name of the deprecated parameter.
|
|
165
|
+
version: The version in which the parameter was deprecated.
|
|
166
|
+
reason: The reason for deprecation and recommended alternative.
|
|
167
|
+
removal_version: The version in which the parameter will be removed.
|
|
168
|
+
stacklevel: Stack level for the warning (default 2 for immediate caller).
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
>>> def my_function(new_param: str, old_param: str | None = None):
|
|
172
|
+
... if old_param is not None:
|
|
173
|
+
... deprecated_parameter(
|
|
174
|
+
... name="old_param",
|
|
175
|
+
... version="1.2.0",
|
|
176
|
+
... reason="Use new_param instead"
|
|
177
|
+
... )
|
|
178
|
+
... new_param = old_param
|
|
179
|
+
... return new_param
|
|
180
|
+
"""
|
|
181
|
+
message = f"Parameter '{name}' is deprecated since version {version}."
|
|
182
|
+
|
|
183
|
+
if removal_version:
|
|
184
|
+
message += f" It will be removed in version {removal_version}."
|
|
185
|
+
|
|
186
|
+
message += f" {reason}"
|
|
187
|
+
|
|
188
|
+
warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel + 1)
|
svc_infra/webhooks/__init__.py
CHANGED
|
@@ -61,7 +61,7 @@ async def trigger_webhook(
|
|
|
61
61
|
return None
|
|
62
62
|
|
|
63
63
|
try:
|
|
64
|
-
msg_id = cast(int, webhook_service.publish(event, data))
|
|
64
|
+
msg_id = cast("int", webhook_service.publish(event, data))
|
|
65
65
|
_logger.info(f"Triggered webhook event '{event}' with message ID {msg_id}")
|
|
66
66
|
return msg_id
|
|
67
67
|
except Exception as e:
|