svc-infra 0.1.589__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/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- 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 +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- 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 +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- 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 +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- 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 +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- 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 +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- 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 -56
- 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/models_schemas/auth/schemas.py.tmpl +1 -1
- 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 +52 -0
- 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 +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- 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.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Document storage operations using svc-infra storage backend.
|
|
3
|
+
|
|
4
|
+
This module provides CRUD operations for document management, storing file content
|
|
5
|
+
in the configured storage backend (S3, local, memory) and metadata in SQL.
|
|
6
|
+
|
|
7
|
+
Quick Start:
|
|
8
|
+
>>> import asyncio
|
|
9
|
+
>>> from svc_infra.storage import easy_storage
|
|
10
|
+
>>> from svc_infra.documents.storage import upload_document, get_document
|
|
11
|
+
>>>
|
|
12
|
+
>>> storage = easy_storage()
|
|
13
|
+
>>> doc = await upload_document(
|
|
14
|
+
... storage=storage,
|
|
15
|
+
... user_id="user_123",
|
|
16
|
+
... file=b"file content",
|
|
17
|
+
... filename="document.pdf",
|
|
18
|
+
... metadata={"category": "legal"}
|
|
19
|
+
... )
|
|
20
|
+
>>> print(doc.id, doc.storage_path)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import mimetypes
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from svc_infra.storage.base import StorageBackend
|
|
33
|
+
|
|
34
|
+
from .models import Document
|
|
35
|
+
|
|
36
|
+
# In-memory metadata storage (production: use SQL database)
|
|
37
|
+
# This is a temporary solution until SQL integration is complete
|
|
38
|
+
_documents_metadata: Dict[str, "Document"] = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def upload_document(
|
|
42
|
+
storage: "StorageBackend",
|
|
43
|
+
user_id: str,
|
|
44
|
+
file: bytes,
|
|
45
|
+
filename: str,
|
|
46
|
+
metadata: Optional[Dict] = None,
|
|
47
|
+
content_type: Optional[str] = None,
|
|
48
|
+
) -> "Document":
|
|
49
|
+
"""
|
|
50
|
+
Upload a document with file content to storage backend.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
storage: Storage backend instance (S3, local, memory)
|
|
54
|
+
user_id: User uploading the document
|
|
55
|
+
file: File content as bytes
|
|
56
|
+
filename: Original filename
|
|
57
|
+
metadata: Optional custom metadata dictionary
|
|
58
|
+
content_type: Optional MIME type (auto-detected if not provided)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Document with storage information and metadata
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
>>> from svc_infra.storage import easy_storage
|
|
65
|
+
>>> storage = easy_storage()
|
|
66
|
+
>>>
|
|
67
|
+
>>> # Upload PDF document
|
|
68
|
+
>>> doc = upload_document(
|
|
69
|
+
... storage=storage,
|
|
70
|
+
... user_id="user_123",
|
|
71
|
+
... file=pdf_bytes,
|
|
72
|
+
... filename="contract.pdf",
|
|
73
|
+
... metadata={"category": "legal", "year": 2024}
|
|
74
|
+
... )
|
|
75
|
+
>>>
|
|
76
|
+
>>> # Upload image
|
|
77
|
+
>>> doc = upload_document(
|
|
78
|
+
... storage=storage,
|
|
79
|
+
... user_id="user_456",
|
|
80
|
+
... file=image_bytes,
|
|
81
|
+
... filename="photo.jpg",
|
|
82
|
+
... content_type="image/jpeg"
|
|
83
|
+
... )
|
|
84
|
+
|
|
85
|
+
Notes:
|
|
86
|
+
- Current: In-memory metadata storage (for development)
|
|
87
|
+
- Production: Store metadata in SQL database using svc-infra SQL helpers
|
|
88
|
+
- Storage path format: documents/{user_id}/{doc_id}/{filename}
|
|
89
|
+
- Checksum: SHA-256 hash for integrity validation
|
|
90
|
+
"""
|
|
91
|
+
from .models import Document
|
|
92
|
+
|
|
93
|
+
# Generate unique document ID
|
|
94
|
+
doc_id = f"doc_{uuid.uuid4().hex[:12]}"
|
|
95
|
+
|
|
96
|
+
# Build storage path with user isolation
|
|
97
|
+
storage_path = f"documents/{user_id}/{doc_id}/{filename}"
|
|
98
|
+
|
|
99
|
+
# Detect content type if not provided
|
|
100
|
+
if content_type is None:
|
|
101
|
+
detected_type, _ = mimetypes.guess_type(filename)
|
|
102
|
+
content_type = detected_type or "application/octet-stream"
|
|
103
|
+
|
|
104
|
+
# Calculate checksum for integrity
|
|
105
|
+
checksum = f"sha256:{hashlib.sha256(file).hexdigest()}"
|
|
106
|
+
|
|
107
|
+
# Upload file to storage backend
|
|
108
|
+
await storage.put(
|
|
109
|
+
storage_path, file, content_type=content_type, metadata=metadata or {}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Create document metadata
|
|
113
|
+
doc = Document(
|
|
114
|
+
id=doc_id,
|
|
115
|
+
user_id=user_id,
|
|
116
|
+
filename=filename,
|
|
117
|
+
file_size=len(file),
|
|
118
|
+
upload_date=datetime.utcnow(),
|
|
119
|
+
storage_path=storage_path,
|
|
120
|
+
content_type=content_type,
|
|
121
|
+
checksum=checksum,
|
|
122
|
+
metadata=metadata or {},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Store metadata (production: use SQL)
|
|
126
|
+
_documents_metadata[doc_id] = doc
|
|
127
|
+
|
|
128
|
+
return doc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_document(document_id: str) -> Optional["Document"]:
|
|
132
|
+
"""
|
|
133
|
+
Get document metadata by ID.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
document_id: Document identifier
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Document metadata or None if not found
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
>>> doc = get_document("doc_abc123")
|
|
143
|
+
>>> if doc:
|
|
144
|
+
... print(doc.filename, doc.file_size)
|
|
145
|
+
"""
|
|
146
|
+
return _documents_metadata.get(document_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def download_document(storage: "StorageBackend", document_id: str) -> bytes:
|
|
150
|
+
"""
|
|
151
|
+
Download document file content from storage.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
storage: Storage backend instance
|
|
155
|
+
document_id: Document identifier
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Document file content as bytes
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If document not found
|
|
162
|
+
|
|
163
|
+
Examples:
|
|
164
|
+
>>> from svc_infra.storage import easy_storage
|
|
165
|
+
>>> storage = easy_storage()
|
|
166
|
+
>>>
|
|
167
|
+
>>> file_data = await download_document(storage, "doc_abc123")
|
|
168
|
+
>>> with open("downloaded.pdf", "wb") as f:
|
|
169
|
+
... f.write(file_data)
|
|
170
|
+
"""
|
|
171
|
+
doc = get_document(document_id)
|
|
172
|
+
if not doc:
|
|
173
|
+
raise ValueError(f"Document not found: {document_id}")
|
|
174
|
+
|
|
175
|
+
# Download from storage backend
|
|
176
|
+
return await storage.get(doc.storage_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def delete_document(storage: "StorageBackend", document_id: str) -> bool:
|
|
180
|
+
"""
|
|
181
|
+
Delete document and its file content.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
storage: Storage backend instance
|
|
185
|
+
document_id: Document identifier
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if deleted, False if not found
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
>>> from svc_infra.storage import easy_storage
|
|
192
|
+
>>> storage = easy_storage()
|
|
193
|
+
>>>
|
|
194
|
+
>>> success = delete_document(storage, "doc_abc123")
|
|
195
|
+
>>> if success:
|
|
196
|
+
... print("Document deleted")
|
|
197
|
+
"""
|
|
198
|
+
doc = get_document(document_id)
|
|
199
|
+
if not doc:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
# Delete from storage backend
|
|
203
|
+
await storage.delete(doc.storage_path)
|
|
204
|
+
|
|
205
|
+
# Delete metadata (production: use SQL)
|
|
206
|
+
del _documents_metadata[document_id]
|
|
207
|
+
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def list_documents(
|
|
212
|
+
user_id: str,
|
|
213
|
+
limit: int = 100,
|
|
214
|
+
offset: int = 0,
|
|
215
|
+
) -> List["Document"]:
|
|
216
|
+
"""
|
|
217
|
+
List user's documents with pagination.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
user_id: User identifier
|
|
221
|
+
limit: Maximum number of documents to return
|
|
222
|
+
offset: Number of documents to skip
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
List of user's documents
|
|
226
|
+
|
|
227
|
+
Examples:
|
|
228
|
+
>>> # Get first page
|
|
229
|
+
>>> docs = list_documents("user_123", limit=20)
|
|
230
|
+
>>>
|
|
231
|
+
>>> # Get second page
|
|
232
|
+
>>> docs = list_documents("user_123", limit=20, offset=20)
|
|
233
|
+
>>>
|
|
234
|
+
>>> # Filter by metadata (future enhancement)
|
|
235
|
+
>>> # docs = list_documents("user_123", filters={"category": "legal"})
|
|
236
|
+
|
|
237
|
+
Notes:
|
|
238
|
+
- Current: In-memory filtering
|
|
239
|
+
- Production: Use SQL queries with proper indexing
|
|
240
|
+
- Future: Add metadata filtering and sorting
|
|
241
|
+
"""
|
|
242
|
+
# Filter by user (production: SQL query)
|
|
243
|
+
user_docs = [doc for doc in _documents_metadata.values() if doc.user_id == user_id]
|
|
244
|
+
|
|
245
|
+
# Sort by upload date (newest first)
|
|
246
|
+
user_docs.sort(key=lambda d: d.upload_date, reverse=True)
|
|
247
|
+
|
|
248
|
+
# Apply pagination
|
|
249
|
+
return user_docs[offset : offset + limit]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def clear_storage() -> None:
|
|
253
|
+
"""
|
|
254
|
+
Clear all document metadata (for testing only).
|
|
255
|
+
|
|
256
|
+
Warning:
|
|
257
|
+
This does NOT delete files from storage backend.
|
|
258
|
+
Only use in test environments.
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
>>> # In tests
|
|
262
|
+
>>> clear_storage()
|
|
263
|
+
"""
|
|
264
|
+
_documents_metadata.clear()
|
svc_infra/dx/add.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def write_ci_workflow(
|
|
7
|
+
*,
|
|
8
|
+
target_dir: str | Path,
|
|
9
|
+
name: str = "ci.yml",
|
|
10
|
+
python_version: str = "3.12",
|
|
11
|
+
) -> Path:
|
|
12
|
+
"""Write a minimal CI workflow file (GitHub Actions) with tests/lint/type steps."""
|
|
13
|
+
p = Path(target_dir) / ".github" / "workflows" / name
|
|
14
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
content = f"""
|
|
16
|
+
name: CI
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
branches: [ main ]
|
|
21
|
+
pull_request:
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
build:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: '{python_version}'
|
|
31
|
+
- name: Install Poetry
|
|
32
|
+
run: pipx install poetry
|
|
33
|
+
- name: Install deps
|
|
34
|
+
run: poetry install
|
|
35
|
+
- name: Lint
|
|
36
|
+
run: poetry run flake8 --select=E,F
|
|
37
|
+
- name: Typecheck
|
|
38
|
+
run: poetry run mypy src
|
|
39
|
+
- name: Tests
|
|
40
|
+
run: poetry run pytest -q -W error
|
|
41
|
+
"""
|
|
42
|
+
p.write_text(content.strip() + "\n")
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_openapi_lint_config(
|
|
47
|
+
*, target_dir: str | Path, name: str = ".redocly.yaml"
|
|
48
|
+
) -> Path:
|
|
49
|
+
"""Write a minimal OpenAPI lint config placeholder (Redocly)."""
|
|
50
|
+
p = Path(target_dir) / name
|
|
51
|
+
content = """
|
|
52
|
+
apis:
|
|
53
|
+
main:
|
|
54
|
+
root: openapi.json
|
|
55
|
+
|
|
56
|
+
rules:
|
|
57
|
+
operation-operationId: warn
|
|
58
|
+
no-unused-components: warn
|
|
59
|
+
security-defined: off
|
|
60
|
+
"""
|
|
61
|
+
p.write_text(content.strip() + "\n")
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["write_ci_workflow", "write_openapi_lint_config"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date as _date
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Commit:
|
|
10
|
+
sha: str
|
|
11
|
+
subject: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_SECTION_ORDER = [
|
|
15
|
+
("feat", "Features"),
|
|
16
|
+
("fix", "Bug Fixes"),
|
|
17
|
+
("perf", "Performance"),
|
|
18
|
+
("refactor", "Refactors"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _classify(subject: str) -> tuple[str, str]:
|
|
23
|
+
"""Return (type, title) where title is display name of the section."""
|
|
24
|
+
lower = subject.strip().lower()
|
|
25
|
+
for t, title in _SECTION_ORDER:
|
|
26
|
+
if lower.startswith(t + ":") or lower.startswith(t + "("):
|
|
27
|
+
return (t, title)
|
|
28
|
+
return ("other", "Other")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _format_item(commit: Commit) -> str:
|
|
32
|
+
subj = commit.subject.strip()
|
|
33
|
+
# Strip leading type(scope): if present
|
|
34
|
+
i = subj.find(": ")
|
|
35
|
+
if i != -1 and i < 20: # conventional commit prefix
|
|
36
|
+
pretty = subj[i + 2 :].strip()
|
|
37
|
+
else:
|
|
38
|
+
pretty = subj
|
|
39
|
+
return f"- {pretty} ({commit.sha})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_release_section(
|
|
43
|
+
*,
|
|
44
|
+
version: str,
|
|
45
|
+
commits: Sequence[Commit],
|
|
46
|
+
release_date: str | None = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Generate a markdown release section from commits.
|
|
49
|
+
|
|
50
|
+
Group by type: feat, fix, perf, refactor; everything else under Other.
|
|
51
|
+
"""
|
|
52
|
+
if release_date is None:
|
|
53
|
+
release_date = _date.today().isoformat()
|
|
54
|
+
|
|
55
|
+
buckets: dict[str, list[str]] = {k: [] for k, _ in _SECTION_ORDER}
|
|
56
|
+
buckets["other"] = []
|
|
57
|
+
|
|
58
|
+
for c in commits:
|
|
59
|
+
typ, _ = _classify(c.subject)
|
|
60
|
+
buckets.setdefault(typ, []).append(_format_item(c))
|
|
61
|
+
|
|
62
|
+
lines: list[str] = [f"## v{version} - {release_date}", ""]
|
|
63
|
+
for key, title in _SECTION_ORDER + [("other", "Other")]:
|
|
64
|
+
items = buckets.get(key) or []
|
|
65
|
+
if not items:
|
|
66
|
+
continue
|
|
67
|
+
lines.append(f"### {title}")
|
|
68
|
+
lines.extend(items)
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["Commit", "generate_release_section"]
|
svc_infra/dx/checks.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _load_json(path: str | Path) -> dict[Any, Any]:
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
p = Path(path)
|
|
11
|
+
return cast(dict[Any, Any], json.loads(p.read_text()))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_openapi_problem_schema(
|
|
15
|
+
schema: dict | None = None, *, path: str | Path | None = None
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Validate OpenAPI has a Problem schema with required fields and formats.
|
|
18
|
+
|
|
19
|
+
Raises ValueError with a descriptive message on failure.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
if schema is None:
|
|
23
|
+
if path is None:
|
|
24
|
+
raise ValueError("either schema or path must be provided")
|
|
25
|
+
schema = _load_json(path)
|
|
26
|
+
|
|
27
|
+
comps = (schema or {}).get("components") or {}
|
|
28
|
+
prob = (comps.get("schemas") or {}).get("Problem")
|
|
29
|
+
if not isinstance(prob, dict):
|
|
30
|
+
raise ValueError("Problem schema missing under components.schemas.Problem")
|
|
31
|
+
|
|
32
|
+
props = prob.get("properties") or {}
|
|
33
|
+
# Required keys presence
|
|
34
|
+
for key in ("type", "title", "status", "detail", "instance", "code"):
|
|
35
|
+
if key not in props:
|
|
36
|
+
raise ValueError(f"Problem.{key} missing in properties")
|
|
37
|
+
|
|
38
|
+
# instance must be uri-reference per our convention
|
|
39
|
+
inst = props.get("instance") or {}
|
|
40
|
+
if inst.get("format") != "uri-reference":
|
|
41
|
+
raise ValueError("Problem.instance must have format 'uri-reference'")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_migrations_up_to_date(*, project_root: str | Path = ".") -> None:
|
|
45
|
+
"""Best-effort migrations check: passes if alembic env present and head is reachable.
|
|
46
|
+
|
|
47
|
+
This is a lightweight stub that can be extended per-project. For now, it checks
|
|
48
|
+
that an Alembic env exists when 'alembic.ini' is present; it does not execute DB calls.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
root = Path(project_root)
|
|
52
|
+
# If alembic.ini is absent, there's nothing to check here
|
|
53
|
+
if not (root / "alembic.ini").exists():
|
|
54
|
+
return
|
|
55
|
+
# Ensure versions/ dir exists under migrations path if configured, default to 'migrations'
|
|
56
|
+
mig_dir = root / "migrations"
|
|
57
|
+
if not mig_dir.exists():
|
|
58
|
+
# tolerate alternative layout via env; keep stub permissive
|
|
59
|
+
return
|
|
60
|
+
versions = mig_dir / "versions"
|
|
61
|
+
if not versions.exists():
|
|
62
|
+
raise ValueError("Alembic migrations directory missing versions/ subfolder")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"check_openapi_problem_schema",
|
|
67
|
+
"check_migrations_up_to_date",
|
|
68
|
+
]
|
svc_infra/exceptions.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Centralized exception re-exports for svc-infra.
|
|
2
|
+
|
|
3
|
+
This module provides a single import point for all svc-infra exceptions.
|
|
4
|
+
Exceptions are organized by domain and all inherit from the base SvcInfraError.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from svc_infra.exceptions import (
|
|
8
|
+
SvcInfraError,
|
|
9
|
+
WebSocketError,
|
|
10
|
+
StorageError,
|
|
11
|
+
FastApiException,
|
|
12
|
+
)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# ruff: noqa: E402
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
# =============================================================================
|
|
23
|
+
# Logging Helper
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def log_exception(
|
|
28
|
+
logger: logging.Logger,
|
|
29
|
+
msg: str,
|
|
30
|
+
exc: Exception,
|
|
31
|
+
*,
|
|
32
|
+
level: str = "warning",
|
|
33
|
+
include_traceback: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Log an exception with consistent formatting.
|
|
36
|
+
|
|
37
|
+
Use this helper instead of bare `except Exception:` blocks to ensure
|
|
38
|
+
all exceptions are properly logged with context.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
logger: The logger instance to use
|
|
42
|
+
msg: Context message describing what operation failed
|
|
43
|
+
exc: The exception that was caught
|
|
44
|
+
level: Log level - "debug", "info", "warning", "error", "critical"
|
|
45
|
+
include_traceback: Whether to include full traceback (exc_info=True)
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
try:
|
|
49
|
+
result = await service.process()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
log_exception(logger, "Failed to process request", e)
|
|
52
|
+
# Handle gracefully or re-raise
|
|
53
|
+
"""
|
|
54
|
+
log_func = getattr(logger, level.lower(), logger.warning)
|
|
55
|
+
log_func(f"{msg}: {type(exc).__name__}: {exc}", exc_info=include_traceback)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# Base Error
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SvcInfraError(Exception):
|
|
64
|
+
"""Base exception for all svc-infra errors.
|
|
65
|
+
|
|
66
|
+
All svc-infra exceptions can be caught with this single class.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
message: Human-readable error description
|
|
70
|
+
details: Additional context as key-value pairs
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
message: str,
|
|
76
|
+
*,
|
|
77
|
+
details: dict[str, Any] | None = None,
|
|
78
|
+
):
|
|
79
|
+
self.message = message
|
|
80
|
+
self.details = details or {}
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return f"{self.__class__.__name__}({self.message!r})"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# Re-exports from submodules
|
|
89
|
+
# =============================================================================
|
|
90
|
+
|
|
91
|
+
# API exceptions
|
|
92
|
+
from svc_infra.api.fastapi.middleware.errors.exceptions import FastApiException
|
|
93
|
+
|
|
94
|
+
# App exceptions
|
|
95
|
+
from svc_infra.app.env import MissingSecretError
|
|
96
|
+
|
|
97
|
+
# Security exceptions
|
|
98
|
+
from svc_infra.security.passwords import PasswordValidationError
|
|
99
|
+
|
|
100
|
+
# Storage exceptions
|
|
101
|
+
from svc_infra.storage.base import FileNotFoundError as StorageFileNotFoundError
|
|
102
|
+
from svc_infra.storage.base import (
|
|
103
|
+
InvalidKeyError,
|
|
104
|
+
PermissionDeniedError,
|
|
105
|
+
QuotaExceededError,
|
|
106
|
+
StorageError,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# WebSocket exceptions
|
|
110
|
+
from svc_infra.websocket.exceptions import AuthenticationError as WebSocketAuthError
|
|
111
|
+
from svc_infra.websocket.exceptions import (
|
|
112
|
+
ConnectionClosedError,
|
|
113
|
+
ConnectionFailedError,
|
|
114
|
+
MessageTooLargeError,
|
|
115
|
+
WebSocketError,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
# Logging helper
|
|
120
|
+
"log_exception",
|
|
121
|
+
# Base
|
|
122
|
+
"SvcInfraError",
|
|
123
|
+
# WebSocket
|
|
124
|
+
"WebSocketError",
|
|
125
|
+
"WebSocketAuthError",
|
|
126
|
+
"ConnectionClosedError",
|
|
127
|
+
"ConnectionFailedError",
|
|
128
|
+
"MessageTooLargeError",
|
|
129
|
+
# Storage
|
|
130
|
+
"StorageError",
|
|
131
|
+
"StorageFileNotFoundError",
|
|
132
|
+
"InvalidKeyError",
|
|
133
|
+
"PermissionDeniedError",
|
|
134
|
+
"QuotaExceededError",
|
|
135
|
+
# API
|
|
136
|
+
"FastApiException",
|
|
137
|
+
# App
|
|
138
|
+
"MissingSecretError",
|
|
139
|
+
# Security
|
|
140
|
+
"PasswordValidationError",
|
|
141
|
+
]
|