svc-infra 0.1.595__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.

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,531 @@
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
+
37
+
38
+ class Platform(StrEnum):
39
+ """Detected deployment platform."""
40
+
41
+ # Developer PaaS
42
+ RAILWAY = "railway"
43
+ RENDER = "render"
44
+ FLY = "fly"
45
+ HEROKU = "heroku"
46
+
47
+ # AWS
48
+ AWS_ECS = "aws_ecs" # ECS/Fargate
49
+ AWS_LAMBDA = "aws_lambda"
50
+ AWS_BEANSTALK = "aws_beanstalk"
51
+
52
+ # Google Cloud
53
+ CLOUD_RUN = "cloud_run"
54
+ APP_ENGINE = "app_engine"
55
+ GCE = "gce"
56
+
57
+ # Azure
58
+ AZURE_CONTAINER_APPS = "azure_container_apps"
59
+ AZURE_FUNCTIONS = "azure_functions"
60
+ AZURE_APP_SERVICE = "azure_app_service"
61
+
62
+ # Container/Orchestration
63
+ KUBERNETES = "kubernetes"
64
+ DOCKER = "docker"
65
+
66
+ # Local development
67
+ LOCAL = "local"
68
+
69
+
70
+ # Platform detection environment variables
71
+ # Each platform sets specific env vars we can detect
72
+ # Order matters: more specific platforms checked first
73
+ PLATFORM_SIGNATURES: dict[Platform, tuple[str, ...]] = {
74
+ # Developer PaaS (most specific first)
75
+ Platform.RAILWAY: (
76
+ "RAILWAY_ENVIRONMENT",
77
+ "RAILWAY_PROJECT_ID",
78
+ "RAILWAY_SERVICE_ID",
79
+ ),
80
+ Platform.RENDER: ("RENDER", "RENDER_SERVICE_ID", "RENDER_INSTANCE_ID"),
81
+ Platform.FLY: ("FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"),
82
+ Platform.HEROKU: ("DYNO", "HEROKU_APP_NAME", "HEROKU_SLUG_COMMIT"),
83
+ # AWS
84
+ Platform.AWS_LAMBDA: ("AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT"),
85
+ Platform.AWS_ECS: ("ECS_CONTAINER_METADATA_URI", "ECS_CONTAINER_METADATA_URI_V4"),
86
+ Platform.AWS_BEANSTALK: ("ELASTIC_BEANSTALK_ENVIRONMENT_NAME",),
87
+ # Google Cloud
88
+ Platform.CLOUD_RUN: ("K_SERVICE", "K_REVISION", "K_CONFIGURATION"),
89
+ Platform.APP_ENGINE: ("GAE_APPLICATION", "GAE_SERVICE", "GAE_VERSION"),
90
+ Platform.GCE: ("GCE_METADATA_HOST",),
91
+ # Azure
92
+ Platform.AZURE_CONTAINER_APPS: (
93
+ "CONTAINER_APP_NAME",
94
+ "CONTAINER_APP_ENV_DNS_SUFFIX",
95
+ ),
96
+ Platform.AZURE_FUNCTIONS: ("FUNCTIONS_WORKER_RUNTIME", "AzureWebJobsStorage"),
97
+ Platform.AZURE_APP_SERVICE: ("WEBSITE_SITE_NAME", "WEBSITE_INSTANCE_ID"),
98
+ # Generic container/orchestration (check last)
99
+ Platform.KUBERNETES: ("KUBERNETES_SERVICE_HOST", "KUBERNETES_PORT"),
100
+ Platform.DOCKER: ("DOCKER_CONTAINER",), # User must set this; no reliable auto-detect
101
+ }
102
+
103
+ # Container detection paths (Linux-specific)
104
+ CONTAINER_MARKERS = (
105
+ "/.dockerenv",
106
+ "/run/.containerenv", # Podman
107
+ )
108
+
109
+
110
+ def _is_in_container() -> bool:
111
+ """
112
+ Detect if running inside a container.
113
+
114
+ Uses multiple heuristics:
115
+ 1. /.dockerenv file exists (Docker)
116
+ 2. /run/.containerenv exists (Podman)
117
+ 3. /proc/1/cgroup contains container-related strings
118
+ """
119
+ # Check marker files
120
+ for marker in CONTAINER_MARKERS:
121
+ if os.path.exists(marker):
122
+ return True
123
+
124
+ # Check cgroup (Linux)
125
+ try:
126
+ with open("/proc/1/cgroup") as f:
127
+ cgroup = f.read()
128
+ if "docker" in cgroup or "kubepods" in cgroup or "containerd" in cgroup:
129
+ return True
130
+ except (FileNotFoundError, PermissionError):
131
+ pass
132
+
133
+ return False
134
+
135
+
136
+ @cache
137
+ def get_platform() -> Platform:
138
+ """
139
+ Detect the current deployment platform.
140
+
141
+ Detection order:
142
+ 1. Check for platform-specific environment variables
143
+ 2. Check for container markers
144
+ 3. Default to LOCAL
145
+
146
+ Returns:
147
+ Platform enum value
148
+
149
+ Example:
150
+ >>> platform = get_platform()
151
+ >>> if platform == Platform.RAILWAY:
152
+ ... # Railway-specific logic
153
+ ... pass
154
+ """
155
+ # Check each platform's signature env vars
156
+ for platform, env_vars in PLATFORM_SIGNATURES.items():
157
+ for var in env_vars:
158
+ if os.environ.get(var):
159
+ return platform
160
+
161
+ # Check for generic container environment
162
+ if _is_in_container():
163
+ return Platform.DOCKER
164
+
165
+ return Platform.LOCAL
166
+
167
+
168
+ # Platform category groupings
169
+ AWS_PLATFORMS = frozenset({Platform.AWS_ECS, Platform.AWS_LAMBDA, Platform.AWS_BEANSTALK})
170
+ GCP_PLATFORMS = frozenset({Platform.CLOUD_RUN, Platform.APP_ENGINE, Platform.GCE})
171
+ AZURE_PLATFORMS = frozenset(
172
+ {
173
+ Platform.AZURE_CONTAINER_APPS,
174
+ Platform.AZURE_FUNCTIONS,
175
+ Platform.AZURE_APP_SERVICE,
176
+ }
177
+ )
178
+ PAAS_PLATFORMS = frozenset({Platform.RAILWAY, Platform.RENDER, Platform.FLY, Platform.HEROKU})
179
+
180
+
181
+ def is_aws() -> bool:
182
+ """Check if running on AWS (ECS, Lambda, Beanstalk)."""
183
+ return get_platform() in AWS_PLATFORMS
184
+
185
+
186
+ def is_gcp() -> bool:
187
+ """Check if running on Google Cloud (Cloud Run, App Engine, GCE)."""
188
+ return get_platform() in GCP_PLATFORMS
189
+
190
+
191
+ def is_azure() -> bool:
192
+ """Check if running on Azure (Container Apps, Functions, App Service)."""
193
+ return get_platform() in AZURE_PLATFORMS
194
+
195
+
196
+ def is_paas() -> bool:
197
+ """Check if running on developer PaaS (Railway, Render, Fly, Heroku)."""
198
+ return get_platform() in PAAS_PLATFORMS
199
+
200
+
201
+ def is_serverless() -> bool:
202
+ """Check if running in serverless environment (Lambda, Cloud Run, Functions)."""
203
+ return get_platform() in {
204
+ Platform.AWS_LAMBDA,
205
+ Platform.CLOUD_RUN,
206
+ Platform.AZURE_FUNCTIONS,
207
+ }
208
+
209
+
210
+ def is_containerized() -> bool:
211
+ """
212
+ Check if running in any containerized environment.
213
+
214
+ This includes PaaS platforms (Railway, Render, Fly, Heroku),
215
+ cloud providers (AWS, GCP, Azure), Kubernetes, Docker, etc.
216
+
217
+ Returns:
218
+ True if in a container/cloud, False if local
219
+
220
+ Example:
221
+ >>> if is_containerized():
222
+ ... # Enable structured logging, disable debug mode
223
+ ... pass
224
+ """
225
+ platform = get_platform()
226
+ return platform != Platform.LOCAL
227
+
228
+
229
+ def is_local() -> bool:
230
+ """
231
+ Check if running in local development environment.
232
+
233
+ Returns:
234
+ True if local, False if deployed
235
+ """
236
+ return get_platform() == Platform.LOCAL
237
+
238
+
239
+ def get_port(default: int = 8000) -> int:
240
+ """
241
+ Get the HTTP port to bind to.
242
+
243
+ All major PaaS platforms set the PORT environment variable.
244
+ Falls back to the provided default for local development.
245
+
246
+ Args:
247
+ default: Port to use if PORT is not set (default: 8000)
248
+
249
+ Returns:
250
+ Port number as integer
251
+
252
+ Example:
253
+ >>> import uvicorn
254
+ >>> uvicorn.run(app, host="0.0.0.0", port=get_port())
255
+ """
256
+ port_str = os.environ.get("PORT", "")
257
+ if port_str.isdigit():
258
+ return int(port_str)
259
+ return default
260
+
261
+
262
+ def get_host(default: str = "127.0.0.1") -> str:
263
+ """
264
+ Get the host address to bind to.
265
+
266
+ In containerized environments, binds to 0.0.0.0 to accept
267
+ external connections. Locally, binds to 127.0.0.1 for security.
268
+
269
+ Args:
270
+ default: Host to use for local development (default: "127.0.0.1")
271
+
272
+ Returns:
273
+ Host address string
274
+
275
+ Example:
276
+ >>> import uvicorn
277
+ >>> uvicorn.run(app, host=get_host(), port=get_port())
278
+ """
279
+ if is_containerized():
280
+ return "0.0.0.0"
281
+ return os.environ.get("HOST", default)
282
+
283
+
284
+ def get_database_url(
285
+ *,
286
+ prefer_private: bool = True,
287
+ normalize: bool = True,
288
+ ) -> str | None:
289
+ """
290
+ Get database URL with platform-aware resolution.
291
+
292
+ Handles platform-specific naming conventions:
293
+ - Railway: DATABASE_URL_PRIVATE (internal) vs DATABASE_URL (public)
294
+ - Render: DATABASE_URL (internal service communication)
295
+ - Heroku: DATABASE_URL (with postgres:// that needs normalization)
296
+
297
+ Args:
298
+ prefer_private: If True, prefer *_PRIVATE variants for internal
299
+ networking (free egress on Railway). Default: True
300
+ normalize: If True, convert postgres:// to postgresql://
301
+ for SQLAlchemy compatibility. Default: True
302
+
303
+ Returns:
304
+ Database URL string or None if not configured
305
+
306
+ Example:
307
+ >>> from sqlalchemy import create_engine
308
+ >>> url = get_database_url()
309
+ >>> if url:
310
+ ... engine = create_engine(url)
311
+ """
312
+ # Railway-specific: prefer private networking for free egress
313
+ if prefer_private:
314
+ url = os.environ.get("DATABASE_URL_PRIVATE")
315
+ if url:
316
+ return _normalize_url(url) if normalize else url
317
+
318
+ # Standard DATABASE_URL (all platforms)
319
+ url = os.environ.get("DATABASE_URL")
320
+ if url:
321
+ return _normalize_url(url) if normalize else url
322
+
323
+ # Legacy svc-infra names
324
+ for var in ("SQL_URL", "DB_URL", "PRIVATE_SQL_URL"):
325
+ url = os.environ.get(var)
326
+ if url:
327
+ return _normalize_url(url) if normalize else url
328
+
329
+ return None
330
+
331
+
332
+ def get_redis_url(*, prefer_private: bool = True) -> str | None:
333
+ """
334
+ Get Redis URL with platform-aware resolution.
335
+
336
+ Similar to get_database_url, handles platform-specific naming:
337
+ - Railway: REDIS_URL_PRIVATE vs REDIS_URL
338
+ - Render: REDIS_URL (internal)
339
+ - Generic: REDIS_URL, CACHE_URL
340
+
341
+ Args:
342
+ prefer_private: Prefer *_PRIVATE variants for internal networking
343
+
344
+ Returns:
345
+ Redis URL string or None if not configured
346
+
347
+ Example:
348
+ >>> from redis import Redis
349
+ >>> url = get_redis_url()
350
+ >>> if url:
351
+ ... redis = Redis.from_url(url)
352
+ """
353
+ if prefer_private:
354
+ url = os.environ.get("REDIS_URL_PRIVATE") or os.environ.get("REDIS_PRIVATE_URL")
355
+ if url:
356
+ return url
357
+
358
+ return (
359
+ os.environ.get("REDIS_URL")
360
+ or os.environ.get("CACHE_URL")
361
+ or os.environ.get("UPSTASH_REDIS_REST_URL")
362
+ )
363
+
364
+
365
+ def _normalize_url(url: str) -> str:
366
+ """
367
+ Normalize database URL for SQLAlchemy compatibility.
368
+
369
+ - Converts postgres:// to postgresql:// (Heroku/Railway legacy)
370
+ - Converts postgres+asyncpg:// to postgresql+asyncpg://
371
+ """
372
+ if url.startswith("postgres://"):
373
+ return "postgresql://" + url[11:]
374
+ if url.startswith("postgres+"):
375
+ return "postgresql+" + url[9:]
376
+ return url
377
+
378
+
379
+ def get_service_url(
380
+ service_name: str,
381
+ *,
382
+ default_port: int = 8000,
383
+ scheme: str = "http",
384
+ ) -> str | None:
385
+ """
386
+ Get URL for an internal service by name.
387
+
388
+ Checks platform-specific service discovery mechanisms:
389
+ - Railway: <SERVICE>_URL env var
390
+ - Kubernetes: <SERVICE>_SERVICE_HOST + <SERVICE>_SERVICE_PORT
391
+ - Generic: <SERVICE>_URL env var
392
+
393
+ Args:
394
+ service_name: Service name (e.g., "api", "worker")
395
+ default_port: Port to use if not discoverable
396
+ scheme: URL scheme (default: "http")
397
+
398
+ Returns:
399
+ Service URL or None if not discoverable
400
+
401
+ Example:
402
+ >>> worker_url = get_service_url("worker")
403
+ >>> if worker_url:
404
+ ... httpx.post(f"{worker_url}/jobs", json=job_data)
405
+ """
406
+ name_upper = service_name.upper().replace("-", "_")
407
+
408
+ # Direct URL env var (Railway, custom)
409
+ url = os.environ.get(f"{name_upper}_URL")
410
+ if url:
411
+ return url
412
+
413
+ # Kubernetes service discovery
414
+ host = os.environ.get(f"{name_upper}_SERVICE_HOST")
415
+ port = os.environ.get(f"{name_upper}_SERVICE_PORT", str(default_port))
416
+ if host:
417
+ return f"{scheme}://{host}:{port}"
418
+
419
+ return None
420
+
421
+
422
+ def get_public_url() -> str | None:
423
+ """
424
+ Get the public URL of this service.
425
+
426
+ Platform-specific resolution:
427
+ - Railway: RAILWAY_PUBLIC_DOMAIN
428
+ - Render: RENDER_EXTERNAL_URL
429
+ - Fly: FLY_APP_NAME.fly.dev
430
+ - Heroku: APP_URL or <app>.herokuapp.com
431
+
432
+ Returns:
433
+ Public HTTPS URL or None if not available
434
+ """
435
+ # Railway
436
+ domain = os.environ.get("RAILWAY_PUBLIC_DOMAIN")
437
+ if domain:
438
+ return f"https://{domain}"
439
+
440
+ # Render
441
+ url = os.environ.get("RENDER_EXTERNAL_URL")
442
+ if url:
443
+ return url
444
+
445
+ # Fly.io
446
+ app_name = os.environ.get("FLY_APP_NAME")
447
+ if app_name:
448
+ return f"https://{app_name}.fly.dev"
449
+
450
+ # Heroku
451
+ url = os.environ.get("APP_URL")
452
+ if url:
453
+ return url
454
+ app_name = os.environ.get("HEROKU_APP_NAME")
455
+ if app_name:
456
+ return f"https://{app_name}.herokuapp.com"
457
+
458
+ return None
459
+
460
+
461
+ def get_environment_name() -> str:
462
+ """
463
+ Get the deployment environment name.
464
+
465
+ Checks platform-specific environment variables:
466
+ - Railway: RAILWAY_ENVIRONMENT
467
+ - Render: RENDER_SERVICE_TYPE or "production"
468
+ - Fly: FLY_APP_NAME suffix convention
469
+ - Generic: APP_ENV, ENVIRONMENT, ENV
470
+
471
+ Returns:
472
+ Environment name (e.g., "production", "staging", "development")
473
+ """
474
+ # Platform-specific
475
+ env = os.environ.get("RAILWAY_ENVIRONMENT")
476
+ if env:
477
+ return env.lower()
478
+
479
+ # Render doesn't have environment name, but has IS_PULL_REQUEST
480
+ if os.environ.get("RENDER"):
481
+ if os.environ.get("IS_PULL_REQUEST") == "true":
482
+ return "preview"
483
+ return "production"
484
+
485
+ # Generic
486
+ return (
487
+ os.environ.get("APP_ENV")
488
+ or os.environ.get("ENVIRONMENT")
489
+ or os.environ.get("ENV")
490
+ or "local"
491
+ ).lower()
492
+
493
+
494
+ def is_production() -> bool:
495
+ """Check if running in production environment."""
496
+ env = get_environment_name()
497
+ return env in ("production", "prod")
498
+
499
+
500
+ def is_preview() -> bool:
501
+ """Check if running in a preview/PR environment."""
502
+ env = get_environment_name()
503
+ return env in ("preview", "pr", "pull_request", "staging")
504
+
505
+
506
+ __all__ = [
507
+ # Platform detection
508
+ "Platform",
509
+ "get_platform",
510
+ "is_containerized",
511
+ "is_local",
512
+ # Cloud provider checks
513
+ "is_aws",
514
+ "is_gcp",
515
+ "is_azure",
516
+ "is_paas",
517
+ "is_serverless",
518
+ # Server binding
519
+ "get_port",
520
+ "get_host",
521
+ # Database/Cache URLs
522
+ "get_database_url",
523
+ "get_redis_url",
524
+ # Service discovery
525
+ "get_service_url",
526
+ "get_public_url",
527
+ # Environment
528
+ "get_environment_name",
529
+ "is_production",
530
+ "is_preview",
531
+ ]
@@ -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
+ ]