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.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -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 +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.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
+ ]