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,538 @@
|
|
|
1
|
+
"""PaaS and deployment utilities for svc-infra applications.
|
|
2
|
+
|
|
3
|
+
This module provides platform detection and environment resolution
|
|
4
|
+
utilities for deploying FastAPI applications to various cloud providers,
|
|
5
|
+
PaaS platforms, and containerized environments.
|
|
6
|
+
|
|
7
|
+
Supported platforms:
|
|
8
|
+
- **Developer PaaS**: Railway, Render, Fly.io, Heroku
|
|
9
|
+
- **AWS**: ECS/Fargate, Lambda, Elastic Beanstalk
|
|
10
|
+
- **Google Cloud**: Cloud Run, App Engine, GCE
|
|
11
|
+
- **Azure**: Container Apps, Functions, App Service
|
|
12
|
+
- **Container**: Kubernetes, Docker, Podman
|
|
13
|
+
|
|
14
|
+
The goal is to abstract away platform-specific environment variable
|
|
15
|
+
naming conventions while allowing full customization.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> from svc_infra.deploy import get_platform, get_port, get_database_url
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Auto-detect platform
|
|
21
|
+
>>> platform = get_platform()
|
|
22
|
+
>>> print(platform) # "railway", "aws_ecs", "cloud_run", "local", etc.
|
|
23
|
+
>>>
|
|
24
|
+
>>> # Get port with platform-aware defaults
|
|
25
|
+
>>> port = get_port() # Reads PORT env var, defaults to 8000
|
|
26
|
+
>>>
|
|
27
|
+
>>> # Get database URL with platform-specific variable resolution
|
|
28
|
+
>>> db_url = get_database_url() # Handles DATABASE_URL_PRIVATE for Railway
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
from functools import cache
|
|
36
|
+
from typing import Optional
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Platform(StrEnum):
|
|
40
|
+
"""Detected deployment platform."""
|
|
41
|
+
|
|
42
|
+
# Developer PaaS
|
|
43
|
+
RAILWAY = "railway"
|
|
44
|
+
RENDER = "render"
|
|
45
|
+
FLY = "fly"
|
|
46
|
+
HEROKU = "heroku"
|
|
47
|
+
|
|
48
|
+
# AWS
|
|
49
|
+
AWS_ECS = "aws_ecs" # ECS/Fargate
|
|
50
|
+
AWS_LAMBDA = "aws_lambda"
|
|
51
|
+
AWS_BEANSTALK = "aws_beanstalk"
|
|
52
|
+
|
|
53
|
+
# Google Cloud
|
|
54
|
+
CLOUD_RUN = "cloud_run"
|
|
55
|
+
APP_ENGINE = "app_engine"
|
|
56
|
+
GCE = "gce"
|
|
57
|
+
|
|
58
|
+
# Azure
|
|
59
|
+
AZURE_CONTAINER_APPS = "azure_container_apps"
|
|
60
|
+
AZURE_FUNCTIONS = "azure_functions"
|
|
61
|
+
AZURE_APP_SERVICE = "azure_app_service"
|
|
62
|
+
|
|
63
|
+
# Container/Orchestration
|
|
64
|
+
KUBERNETES = "kubernetes"
|
|
65
|
+
DOCKER = "docker"
|
|
66
|
+
|
|
67
|
+
# Local development
|
|
68
|
+
LOCAL = "local"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Platform detection environment variables
|
|
72
|
+
# Each platform sets specific env vars we can detect
|
|
73
|
+
# Order matters: more specific platforms checked first
|
|
74
|
+
PLATFORM_SIGNATURES: dict[Platform, tuple[str, ...]] = {
|
|
75
|
+
# Developer PaaS (most specific first)
|
|
76
|
+
Platform.RAILWAY: (
|
|
77
|
+
"RAILWAY_ENVIRONMENT",
|
|
78
|
+
"RAILWAY_PROJECT_ID",
|
|
79
|
+
"RAILWAY_SERVICE_ID",
|
|
80
|
+
),
|
|
81
|
+
Platform.RENDER: ("RENDER", "RENDER_SERVICE_ID", "RENDER_INSTANCE_ID"),
|
|
82
|
+
Platform.FLY: ("FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"),
|
|
83
|
+
Platform.HEROKU: ("DYNO", "HEROKU_APP_NAME", "HEROKU_SLUG_COMMIT"),
|
|
84
|
+
# AWS
|
|
85
|
+
Platform.AWS_LAMBDA: ("AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT"),
|
|
86
|
+
Platform.AWS_ECS: ("ECS_CONTAINER_METADATA_URI", "ECS_CONTAINER_METADATA_URI_V4"),
|
|
87
|
+
Platform.AWS_BEANSTALK: ("ELASTIC_BEANSTALK_ENVIRONMENT_NAME",),
|
|
88
|
+
# Google Cloud
|
|
89
|
+
Platform.CLOUD_RUN: ("K_SERVICE", "K_REVISION", "K_CONFIGURATION"),
|
|
90
|
+
Platform.APP_ENGINE: ("GAE_APPLICATION", "GAE_SERVICE", "GAE_VERSION"),
|
|
91
|
+
Platform.GCE: ("GCE_METADATA_HOST",),
|
|
92
|
+
# Azure
|
|
93
|
+
Platform.AZURE_CONTAINER_APPS: (
|
|
94
|
+
"CONTAINER_APP_NAME",
|
|
95
|
+
"CONTAINER_APP_ENV_DNS_SUFFIX",
|
|
96
|
+
),
|
|
97
|
+
Platform.AZURE_FUNCTIONS: ("FUNCTIONS_WORKER_RUNTIME", "AzureWebJobsStorage"),
|
|
98
|
+
Platform.AZURE_APP_SERVICE: ("WEBSITE_SITE_NAME", "WEBSITE_INSTANCE_ID"),
|
|
99
|
+
# Generic container/orchestration (check last)
|
|
100
|
+
Platform.KUBERNETES: ("KUBERNETES_SERVICE_HOST", "KUBERNETES_PORT"),
|
|
101
|
+
Platform.DOCKER: (
|
|
102
|
+
"DOCKER_CONTAINER",
|
|
103
|
+
), # User must set this; no reliable auto-detect
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Container detection paths (Linux-specific)
|
|
107
|
+
CONTAINER_MARKERS = (
|
|
108
|
+
"/.dockerenv",
|
|
109
|
+
"/run/.containerenv", # Podman
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_in_container() -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Detect if running inside a container.
|
|
116
|
+
|
|
117
|
+
Uses multiple heuristics:
|
|
118
|
+
1. /.dockerenv file exists (Docker)
|
|
119
|
+
2. /run/.containerenv exists (Podman)
|
|
120
|
+
3. /proc/1/cgroup contains container-related strings
|
|
121
|
+
"""
|
|
122
|
+
# Check marker files
|
|
123
|
+
for marker in CONTAINER_MARKERS:
|
|
124
|
+
if os.path.exists(marker):
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
# Check cgroup (Linux)
|
|
128
|
+
try:
|
|
129
|
+
with open("/proc/1/cgroup", "r") as f:
|
|
130
|
+
cgroup = f.read()
|
|
131
|
+
if "docker" in cgroup or "kubepods" in cgroup or "containerd" in cgroup:
|
|
132
|
+
return True
|
|
133
|
+
except (FileNotFoundError, PermissionError):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@cache
|
|
140
|
+
def get_platform() -> Platform:
|
|
141
|
+
"""
|
|
142
|
+
Detect the current deployment platform.
|
|
143
|
+
|
|
144
|
+
Detection order:
|
|
145
|
+
1. Check for platform-specific environment variables
|
|
146
|
+
2. Check for container markers
|
|
147
|
+
3. Default to LOCAL
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Platform enum value
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
>>> platform = get_platform()
|
|
154
|
+
>>> if platform == Platform.RAILWAY:
|
|
155
|
+
... # Railway-specific logic
|
|
156
|
+
... pass
|
|
157
|
+
"""
|
|
158
|
+
# Check each platform's signature env vars
|
|
159
|
+
for platform, env_vars in PLATFORM_SIGNATURES.items():
|
|
160
|
+
for var in env_vars:
|
|
161
|
+
if os.environ.get(var):
|
|
162
|
+
return platform
|
|
163
|
+
|
|
164
|
+
# Check for generic container environment
|
|
165
|
+
if _is_in_container():
|
|
166
|
+
return Platform.DOCKER
|
|
167
|
+
|
|
168
|
+
return Platform.LOCAL
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Platform category groupings
|
|
172
|
+
AWS_PLATFORMS = frozenset(
|
|
173
|
+
{Platform.AWS_ECS, Platform.AWS_LAMBDA, Platform.AWS_BEANSTALK}
|
|
174
|
+
)
|
|
175
|
+
GCP_PLATFORMS = frozenset({Platform.CLOUD_RUN, Platform.APP_ENGINE, Platform.GCE})
|
|
176
|
+
AZURE_PLATFORMS = frozenset(
|
|
177
|
+
{
|
|
178
|
+
Platform.AZURE_CONTAINER_APPS,
|
|
179
|
+
Platform.AZURE_FUNCTIONS,
|
|
180
|
+
Platform.AZURE_APP_SERVICE,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
PAAS_PLATFORMS = frozenset(
|
|
184
|
+
{Platform.RAILWAY, Platform.RENDER, Platform.FLY, Platform.HEROKU}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_aws() -> bool:
|
|
189
|
+
"""Check if running on AWS (ECS, Lambda, Beanstalk)."""
|
|
190
|
+
return get_platform() in AWS_PLATFORMS
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def is_gcp() -> bool:
|
|
194
|
+
"""Check if running on Google Cloud (Cloud Run, App Engine, GCE)."""
|
|
195
|
+
return get_platform() in GCP_PLATFORMS
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def is_azure() -> bool:
|
|
199
|
+
"""Check if running on Azure (Container Apps, Functions, App Service)."""
|
|
200
|
+
return get_platform() in AZURE_PLATFORMS
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def is_paas() -> bool:
|
|
204
|
+
"""Check if running on developer PaaS (Railway, Render, Fly, Heroku)."""
|
|
205
|
+
return get_platform() in PAAS_PLATFORMS
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_serverless() -> bool:
|
|
209
|
+
"""Check if running in serverless environment (Lambda, Cloud Run, Functions)."""
|
|
210
|
+
return get_platform() in {
|
|
211
|
+
Platform.AWS_LAMBDA,
|
|
212
|
+
Platform.CLOUD_RUN,
|
|
213
|
+
Platform.AZURE_FUNCTIONS,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def is_containerized() -> bool:
|
|
218
|
+
"""
|
|
219
|
+
Check if running in any containerized environment.
|
|
220
|
+
|
|
221
|
+
This includes PaaS platforms (Railway, Render, Fly, Heroku),
|
|
222
|
+
cloud providers (AWS, GCP, Azure), Kubernetes, Docker, etc.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if in a container/cloud, False if local
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> if is_containerized():
|
|
229
|
+
... # Enable structured logging, disable debug mode
|
|
230
|
+
... pass
|
|
231
|
+
"""
|
|
232
|
+
platform = get_platform()
|
|
233
|
+
return platform != Platform.LOCAL
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def is_local() -> bool:
|
|
237
|
+
"""
|
|
238
|
+
Check if running in local development environment.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if local, False if deployed
|
|
242
|
+
"""
|
|
243
|
+
return get_platform() == Platform.LOCAL
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_port(default: int = 8000) -> int:
|
|
247
|
+
"""
|
|
248
|
+
Get the HTTP port to bind to.
|
|
249
|
+
|
|
250
|
+
All major PaaS platforms set the PORT environment variable.
|
|
251
|
+
Falls back to the provided default for local development.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
default: Port to use if PORT is not set (default: 8000)
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Port number as integer
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
>>> import uvicorn
|
|
261
|
+
>>> uvicorn.run(app, host="0.0.0.0", port=get_port())
|
|
262
|
+
"""
|
|
263
|
+
port_str = os.environ.get("PORT", "")
|
|
264
|
+
if port_str.isdigit():
|
|
265
|
+
return int(port_str)
|
|
266
|
+
return default
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_host(default: str = "127.0.0.1") -> str:
|
|
270
|
+
"""
|
|
271
|
+
Get the host address to bind to.
|
|
272
|
+
|
|
273
|
+
In containerized environments, binds to 0.0.0.0 to accept
|
|
274
|
+
external connections. Locally, binds to 127.0.0.1 for security.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
default: Host to use for local development (default: "127.0.0.1")
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Host address string
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
>>> import uvicorn
|
|
284
|
+
>>> uvicorn.run(app, host=get_host(), port=get_port())
|
|
285
|
+
"""
|
|
286
|
+
if is_containerized():
|
|
287
|
+
return "0.0.0.0"
|
|
288
|
+
return os.environ.get("HOST", default)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_database_url(
|
|
292
|
+
*,
|
|
293
|
+
prefer_private: bool = True,
|
|
294
|
+
normalize: bool = True,
|
|
295
|
+
) -> Optional[str]:
|
|
296
|
+
"""
|
|
297
|
+
Get database URL with platform-aware resolution.
|
|
298
|
+
|
|
299
|
+
Handles platform-specific naming conventions:
|
|
300
|
+
- Railway: DATABASE_URL_PRIVATE (internal) vs DATABASE_URL (public)
|
|
301
|
+
- Render: DATABASE_URL (internal service communication)
|
|
302
|
+
- Heroku: DATABASE_URL (with postgres:// that needs normalization)
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
prefer_private: If True, prefer *_PRIVATE variants for internal
|
|
306
|
+
networking (free egress on Railway). Default: True
|
|
307
|
+
normalize: If True, convert postgres:// to postgresql://
|
|
308
|
+
for SQLAlchemy compatibility. Default: True
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Database URL string or None if not configured
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
>>> from sqlalchemy import create_engine
|
|
315
|
+
>>> url = get_database_url()
|
|
316
|
+
>>> if url:
|
|
317
|
+
... engine = create_engine(url)
|
|
318
|
+
"""
|
|
319
|
+
# Railway-specific: prefer private networking for free egress
|
|
320
|
+
if prefer_private:
|
|
321
|
+
url = os.environ.get("DATABASE_URL_PRIVATE")
|
|
322
|
+
if url:
|
|
323
|
+
return _normalize_url(url) if normalize else url
|
|
324
|
+
|
|
325
|
+
# Standard DATABASE_URL (all platforms)
|
|
326
|
+
url = os.environ.get("DATABASE_URL")
|
|
327
|
+
if url:
|
|
328
|
+
return _normalize_url(url) if normalize else url
|
|
329
|
+
|
|
330
|
+
# Legacy svc-infra names
|
|
331
|
+
for var in ("SQL_URL", "DB_URL", "PRIVATE_SQL_URL"):
|
|
332
|
+
url = os.environ.get(var)
|
|
333
|
+
if url:
|
|
334
|
+
return _normalize_url(url) if normalize else url
|
|
335
|
+
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def get_redis_url(*, prefer_private: bool = True) -> Optional[str]:
|
|
340
|
+
"""
|
|
341
|
+
Get Redis URL with platform-aware resolution.
|
|
342
|
+
|
|
343
|
+
Similar to get_database_url, handles platform-specific naming:
|
|
344
|
+
- Railway: REDIS_URL_PRIVATE vs REDIS_URL
|
|
345
|
+
- Render: REDIS_URL (internal)
|
|
346
|
+
- Generic: REDIS_URL, CACHE_URL
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
prefer_private: Prefer *_PRIVATE variants for internal networking
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Redis URL string or None if not configured
|
|
353
|
+
|
|
354
|
+
Example:
|
|
355
|
+
>>> from redis import Redis
|
|
356
|
+
>>> url = get_redis_url()
|
|
357
|
+
>>> if url:
|
|
358
|
+
... redis = Redis.from_url(url)
|
|
359
|
+
"""
|
|
360
|
+
if prefer_private:
|
|
361
|
+
url = os.environ.get("REDIS_URL_PRIVATE") or os.environ.get("REDIS_PRIVATE_URL")
|
|
362
|
+
if url:
|
|
363
|
+
return url
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
os.environ.get("REDIS_URL")
|
|
367
|
+
or os.environ.get("CACHE_URL")
|
|
368
|
+
or os.environ.get("UPSTASH_REDIS_REST_URL")
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _normalize_url(url: str) -> str:
|
|
373
|
+
"""
|
|
374
|
+
Normalize database URL for SQLAlchemy compatibility.
|
|
375
|
+
|
|
376
|
+
- Converts postgres:// to postgresql:// (Heroku/Railway legacy)
|
|
377
|
+
- Converts postgres+asyncpg:// to postgresql+asyncpg://
|
|
378
|
+
"""
|
|
379
|
+
if url.startswith("postgres://"):
|
|
380
|
+
return "postgresql://" + url[11:]
|
|
381
|
+
if url.startswith("postgres+"):
|
|
382
|
+
return "postgresql+" + url[9:]
|
|
383
|
+
return url
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def get_service_url(
|
|
387
|
+
service_name: str,
|
|
388
|
+
*,
|
|
389
|
+
default_port: int = 8000,
|
|
390
|
+
scheme: str = "http",
|
|
391
|
+
) -> Optional[str]:
|
|
392
|
+
"""
|
|
393
|
+
Get URL for an internal service by name.
|
|
394
|
+
|
|
395
|
+
Checks platform-specific service discovery mechanisms:
|
|
396
|
+
- Railway: <SERVICE>_URL env var
|
|
397
|
+
- Kubernetes: <SERVICE>_SERVICE_HOST + <SERVICE>_SERVICE_PORT
|
|
398
|
+
- Generic: <SERVICE>_URL env var
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
service_name: Service name (e.g., "api", "worker")
|
|
402
|
+
default_port: Port to use if not discoverable
|
|
403
|
+
scheme: URL scheme (default: "http")
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Service URL or None if not discoverable
|
|
407
|
+
|
|
408
|
+
Example:
|
|
409
|
+
>>> worker_url = get_service_url("worker")
|
|
410
|
+
>>> if worker_url:
|
|
411
|
+
... httpx.post(f"{worker_url}/jobs", json=job_data)
|
|
412
|
+
"""
|
|
413
|
+
name_upper = service_name.upper().replace("-", "_")
|
|
414
|
+
|
|
415
|
+
# Direct URL env var (Railway, custom)
|
|
416
|
+
url = os.environ.get(f"{name_upper}_URL")
|
|
417
|
+
if url:
|
|
418
|
+
return url
|
|
419
|
+
|
|
420
|
+
# Kubernetes service discovery
|
|
421
|
+
host = os.environ.get(f"{name_upper}_SERVICE_HOST")
|
|
422
|
+
port = os.environ.get(f"{name_upper}_SERVICE_PORT", str(default_port))
|
|
423
|
+
if host:
|
|
424
|
+
return f"{scheme}://{host}:{port}"
|
|
425
|
+
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def get_public_url() -> Optional[str]:
|
|
430
|
+
"""
|
|
431
|
+
Get the public URL of this service.
|
|
432
|
+
|
|
433
|
+
Platform-specific resolution:
|
|
434
|
+
- Railway: RAILWAY_PUBLIC_DOMAIN
|
|
435
|
+
- Render: RENDER_EXTERNAL_URL
|
|
436
|
+
- Fly: FLY_APP_NAME.fly.dev
|
|
437
|
+
- Heroku: APP_URL or <app>.herokuapp.com
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Public HTTPS URL or None if not available
|
|
441
|
+
"""
|
|
442
|
+
# Railway
|
|
443
|
+
domain = os.environ.get("RAILWAY_PUBLIC_DOMAIN")
|
|
444
|
+
if domain:
|
|
445
|
+
return f"https://{domain}"
|
|
446
|
+
|
|
447
|
+
# Render
|
|
448
|
+
url = os.environ.get("RENDER_EXTERNAL_URL")
|
|
449
|
+
if url:
|
|
450
|
+
return url
|
|
451
|
+
|
|
452
|
+
# Fly.io
|
|
453
|
+
app_name = os.environ.get("FLY_APP_NAME")
|
|
454
|
+
if app_name:
|
|
455
|
+
return f"https://{app_name}.fly.dev"
|
|
456
|
+
|
|
457
|
+
# Heroku
|
|
458
|
+
url = os.environ.get("APP_URL")
|
|
459
|
+
if url:
|
|
460
|
+
return url
|
|
461
|
+
app_name = os.environ.get("HEROKU_APP_NAME")
|
|
462
|
+
if app_name:
|
|
463
|
+
return f"https://{app_name}.herokuapp.com"
|
|
464
|
+
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def get_environment_name() -> str:
|
|
469
|
+
"""
|
|
470
|
+
Get the deployment environment name.
|
|
471
|
+
|
|
472
|
+
Checks platform-specific environment variables:
|
|
473
|
+
- Railway: RAILWAY_ENVIRONMENT
|
|
474
|
+
- Render: RENDER_SERVICE_TYPE or "production"
|
|
475
|
+
- Fly: FLY_APP_NAME suffix convention
|
|
476
|
+
- Generic: APP_ENV, ENVIRONMENT, ENV
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Environment name (e.g., "production", "staging", "development")
|
|
480
|
+
"""
|
|
481
|
+
# Platform-specific
|
|
482
|
+
env = os.environ.get("RAILWAY_ENVIRONMENT")
|
|
483
|
+
if env:
|
|
484
|
+
return env.lower()
|
|
485
|
+
|
|
486
|
+
# Render doesn't have environment name, but has IS_PULL_REQUEST
|
|
487
|
+
if os.environ.get("RENDER"):
|
|
488
|
+
if os.environ.get("IS_PULL_REQUEST") == "true":
|
|
489
|
+
return "preview"
|
|
490
|
+
return "production"
|
|
491
|
+
|
|
492
|
+
# Generic
|
|
493
|
+
return (
|
|
494
|
+
os.environ.get("APP_ENV")
|
|
495
|
+
or os.environ.get("ENVIRONMENT")
|
|
496
|
+
or os.environ.get("ENV")
|
|
497
|
+
or "local"
|
|
498
|
+
).lower()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def is_production() -> bool:
|
|
502
|
+
"""Check if running in production environment."""
|
|
503
|
+
env = get_environment_name()
|
|
504
|
+
return env in ("production", "prod")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def is_preview() -> bool:
|
|
508
|
+
"""Check if running in a preview/PR environment."""
|
|
509
|
+
env = get_environment_name()
|
|
510
|
+
return env in ("preview", "pr", "pull_request", "staging")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
__all__ = [
|
|
514
|
+
# Platform detection
|
|
515
|
+
"Platform",
|
|
516
|
+
"get_platform",
|
|
517
|
+
"is_containerized",
|
|
518
|
+
"is_local",
|
|
519
|
+
# Cloud provider checks
|
|
520
|
+
"is_aws",
|
|
521
|
+
"is_gcp",
|
|
522
|
+
"is_azure",
|
|
523
|
+
"is_paas",
|
|
524
|
+
"is_serverless",
|
|
525
|
+
# Server binding
|
|
526
|
+
"get_port",
|
|
527
|
+
"get_host",
|
|
528
|
+
# Database/Cache URLs
|
|
529
|
+
"get_database_url",
|
|
530
|
+
"get_redis_url",
|
|
531
|
+
# Service discovery
|
|
532
|
+
"get_service_url",
|
|
533
|
+
"get_public_url",
|
|
534
|
+
# Environment
|
|
535
|
+
"get_environment_name",
|
|
536
|
+
"is_production",
|
|
537
|
+
"is_preview",
|
|
538
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic document management for svc-infra.
|
|
3
|
+
|
|
4
|
+
This module provides domain-agnostic document storage and metadata management
|
|
5
|
+
that works with any storage backend (S3, local, memory). For domain-specific
|
|
6
|
+
extensions (e.g., OCR for tax forms, medical record parsing), see fin-infra
|
|
7
|
+
as a reference implementation.
|
|
8
|
+
|
|
9
|
+
Quick Start:
|
|
10
|
+
>>> from svc_infra.documents import easy_documents
|
|
11
|
+
>>>
|
|
12
|
+
>>> # Create manager (auto-detects storage backend)
|
|
13
|
+
>>> manager = easy_documents()
|
|
14
|
+
>>>
|
|
15
|
+
>>> # Upload document
|
|
16
|
+
>>> doc = manager.upload(
|
|
17
|
+
... user_id="user_123",
|
|
18
|
+
... file=file_bytes,
|
|
19
|
+
... filename="contract.pdf",
|
|
20
|
+
... metadata={"category": "legal", "year": 2024}
|
|
21
|
+
... )
|
|
22
|
+
>>>
|
|
23
|
+
>>> # List documents
|
|
24
|
+
>>> docs = manager.list(user_id="user_123")
|
|
25
|
+
>>>
|
|
26
|
+
>>> # Download document
|
|
27
|
+
>>> file_data = manager.download(doc.id)
|
|
28
|
+
>>>
|
|
29
|
+
>>> # Delete document
|
|
30
|
+
>>> manager.delete(doc.id)
|
|
31
|
+
|
|
32
|
+
FastAPI Integration:
|
|
33
|
+
>>> from fastapi import FastAPI
|
|
34
|
+
>>> from svc_infra.documents import add_documents
|
|
35
|
+
>>>
|
|
36
|
+
>>> app = FastAPI()
|
|
37
|
+
>>> manager = add_documents(app)
|
|
38
|
+
>>>
|
|
39
|
+
>>> # Routes available:
|
|
40
|
+
>>> # POST /documents/upload
|
|
41
|
+
>>> # GET /documents/{id}
|
|
42
|
+
>>> # GET /documents/list
|
|
43
|
+
>>> # DELETE /documents/{id}
|
|
44
|
+
|
|
45
|
+
Architecture:
|
|
46
|
+
- Generic document model with flexible metadata
|
|
47
|
+
- Storage backend integration (uses svc-infra.storage)
|
|
48
|
+
- SQL metadata storage (currently in-memory, SQL coming soon)
|
|
49
|
+
- FastAPI router with 4 endpoints
|
|
50
|
+
- No domain-specific logic (extensible for any use case)
|
|
51
|
+
|
|
52
|
+
Extension Pattern:
|
|
53
|
+
For domain-specific features, import from this module and extend:
|
|
54
|
+
|
|
55
|
+
>>> # fin-infra example (financial documents with OCR/AI)
|
|
56
|
+
>>> from svc_infra.documents import Document, DocumentManager
|
|
57
|
+
>>>
|
|
58
|
+
>>> class FinancialDocument(Document):
|
|
59
|
+
... '''Extends base with financial fields'''
|
|
60
|
+
... tax_year: int
|
|
61
|
+
... form_type: str
|
|
62
|
+
>>>
|
|
63
|
+
>>> class FinancialDocumentManager(DocumentManager):
|
|
64
|
+
... '''Extends base with OCR and AI analysis'''
|
|
65
|
+
... def extract_text(self, doc_id: str):
|
|
66
|
+
... '''OCR for tax forms'''
|
|
67
|
+
... pass
|
|
68
|
+
... def analyze(self, doc_id: str):
|
|
69
|
+
... '''AI-powered insights'''
|
|
70
|
+
... pass
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from .add import add_documents
|
|
74
|
+
from .ease import DocumentManager, easy_documents
|
|
75
|
+
from .models import Document
|
|
76
|
+
from .storage import (
|
|
77
|
+
clear_storage,
|
|
78
|
+
delete_document,
|
|
79
|
+
download_document,
|
|
80
|
+
get_document,
|
|
81
|
+
list_documents,
|
|
82
|
+
upload_document,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
# Models
|
|
87
|
+
"Document",
|
|
88
|
+
# Manager
|
|
89
|
+
"DocumentManager",
|
|
90
|
+
"easy_documents",
|
|
91
|
+
# Storage operations
|
|
92
|
+
"upload_document",
|
|
93
|
+
"get_document",
|
|
94
|
+
"download_document",
|
|
95
|
+
"delete_document",
|
|
96
|
+
"list_documents",
|
|
97
|
+
"clear_storage",
|
|
98
|
+
# FastAPI integration
|
|
99
|
+
"add_documents",
|
|
100
|
+
]
|